settings_ui.rs

   1//! # settings_ui
   2mod components;
   3use editor::{Editor, EditorEvent};
   4use feature_flags::{FeatureFlag, FeatureFlagAppExt as _};
   5use fuzzy::StringMatchCandidate;
   6use gpui::{
   7    App, AppContext as _, Context, Div, Entity, Global, IntoElement, ReadGlobal as _, Render, Task,
   8    TitlebarOptions, UniformListScrollHandle, Window, WindowHandle, WindowOptions, actions, div,
   9    point, px, size, uniform_list,
  10};
  11use project::WorktreeId;
  12use settings::{CursorShape, SaturatingBool, SettingsContent, SettingsStore};
  13use std::{
  14    any::{Any, TypeId, type_name},
  15    cell::RefCell,
  16    collections::HashMap,
  17    ops::Range,
  18    rc::Rc,
  19    sync::{Arc, atomic::AtomicBool},
  20};
  21use ui::{Divider, DropdownMenu, ListItem, Switch, prelude::*};
  22use util::{paths::PathStyle, rel_path::RelPath};
  23
  24use crate::components::SettingsEditor;
  25
  26#[derive(Clone, Copy)]
  27struct SettingField<T: 'static> {
  28    pick: fn(&SettingsContent) -> &Option<T>,
  29    pick_mut: fn(&mut SettingsContent) -> &mut Option<T>,
  30}
  31
  32trait AnySettingField {
  33    fn as_any(&self) -> &dyn Any;
  34    fn type_name(&self) -> &'static str;
  35    fn type_id(&self) -> TypeId;
  36    fn file_set_in(&self, file: SettingsUiFile, cx: &App) -> settings::SettingsFile;
  37}
  38
  39impl<T> AnySettingField for SettingField<T> {
  40    fn as_any(&self) -> &dyn Any {
  41        self
  42    }
  43
  44    fn type_name(&self) -> &'static str {
  45        type_name::<T>()
  46    }
  47
  48    fn type_id(&self) -> TypeId {
  49        TypeId::of::<T>()
  50    }
  51
  52    fn file_set_in(&self, file: SettingsUiFile, cx: &App) -> settings::SettingsFile {
  53        let (file, _) = cx
  54            .global::<SettingsStore>()
  55            .get_value_from_file(file.to_settings(), self.pick);
  56        return file;
  57    }
  58}
  59
  60#[derive(Default, Clone)]
  61struct SettingFieldRenderer {
  62    renderers: Rc<
  63        RefCell<
  64            HashMap<
  65                TypeId,
  66                Box<
  67                    dyn Fn(
  68                        &dyn AnySettingField,
  69                        SettingsUiFile,
  70                        Option<&SettingsFieldMetadata>,
  71                        &mut Window,
  72                        &mut App,
  73                    ) -> AnyElement,
  74                >,
  75            >,
  76        >,
  77    >,
  78}
  79
  80impl Global for SettingFieldRenderer {}
  81
  82impl SettingFieldRenderer {
  83    fn add_renderer<T: 'static>(
  84        &mut self,
  85        renderer: impl Fn(
  86            &SettingField<T>,
  87            SettingsUiFile,
  88            Option<&SettingsFieldMetadata>,
  89            &mut Window,
  90            &mut App,
  91        ) -> AnyElement
  92        + 'static,
  93    ) -> &mut Self {
  94        let key = TypeId::of::<T>();
  95        let renderer = Box::new(
  96            move |any_setting_field: &dyn AnySettingField,
  97                  settings_file: SettingsUiFile,
  98                  metadata: Option<&SettingsFieldMetadata>,
  99                  window: &mut Window,
 100                  cx: &mut App| {
 101                let field = any_setting_field
 102                    .as_any()
 103                    .downcast_ref::<SettingField<T>>()
 104                    .unwrap();
 105                renderer(field, settings_file, metadata, window, cx)
 106            },
 107        );
 108        self.renderers.borrow_mut().insert(key, renderer);
 109        self
 110    }
 111
 112    fn render(
 113        &self,
 114        any_setting_field: &dyn AnySettingField,
 115        settings_file: SettingsUiFile,
 116        metadata: Option<&SettingsFieldMetadata>,
 117        window: &mut Window,
 118        cx: &mut App,
 119    ) -> AnyElement {
 120        let key = any_setting_field.type_id();
 121        if let Some(renderer) = self.renderers.borrow().get(&key) {
 122            renderer(any_setting_field, settings_file, metadata, window, cx)
 123        } else {
 124            panic!(
 125                "No renderer found for type: {}",
 126                any_setting_field.type_name()
 127            )
 128        }
 129    }
 130}
 131
 132struct SettingsFieldMetadata {
 133    placeholder: Option<&'static str>,
 134}
 135
 136fn user_settings_data() -> Vec<SettingsPage> {
 137    vec![
 138        SettingsPage {
 139            title: "General Page",
 140            expanded: true,
 141            items: vec![
 142                SettingsPageItem::SectionHeader("General"),
 143                SettingsPageItem::SettingItem(SettingItem {
 144                    title: "Confirm Quit",
 145                    description: "Whether to confirm before quitting Zed",
 146                    field: Box::new(SettingField {
 147                        pick: |settings_content| &settings_content.workspace.confirm_quit,
 148                        pick_mut: |settings_content| &mut settings_content.workspace.confirm_quit,
 149                    }),
 150                    metadata: None,
 151                }),
 152                SettingsPageItem::SettingItem(SettingItem {
 153                    title: "Auto Update",
 154                    description: "Automatically update Zed (may be ignored on Linux if installed through a package manager)",
 155                    field: Box::new(SettingField {
 156                        pick: |settings_content| &settings_content.auto_update,
 157                        pick_mut: |settings_content| &mut settings_content.auto_update,
 158                    }),
 159                    metadata: None,
 160                }),
 161                SettingsPageItem::SectionHeader("Privacy"),
 162            ],
 163        },
 164        SettingsPage {
 165            title: "Project",
 166            expanded: true,
 167            items: vec![
 168                SettingsPageItem::SectionHeader("Worktree Settings Content"),
 169                SettingsPageItem::SettingItem(SettingItem {
 170                    title: "Project Name",
 171                    description: "The displayed name of this project. If not set, the root directory name",
 172                    field: Box::new(SettingField {
 173                        pick: |settings_content| &settings_content.project.worktree.project_name,
 174                        pick_mut: |settings_content| {
 175                            &mut settings_content.project.worktree.project_name
 176                        },
 177                    }),
 178                    metadata: Some(Box::new(SettingsFieldMetadata {
 179                        placeholder: Some("A new name"),
 180                    })),
 181                }),
 182            ],
 183        },
 184        SettingsPage {
 185            title: "AI",
 186            expanded: true,
 187            items: vec![
 188                SettingsPageItem::SectionHeader("General"),
 189                SettingsPageItem::SettingItem(SettingItem {
 190                    title: "Disable AI",
 191                    description: "Whether to disable all AI features in Zed",
 192                    field: Box::new(SettingField {
 193                        pick: |settings_content| &settings_content.disable_ai,
 194                        pick_mut: |settings_content| &mut settings_content.disable_ai,
 195                    }),
 196                    metadata: None,
 197                }),
 198            ],
 199        },
 200        SettingsPage {
 201            title: "Appearance & Behavior",
 202            expanded: true,
 203            items: vec![
 204                SettingsPageItem::SectionHeader("Cursor"),
 205                SettingsPageItem::SettingItem(SettingItem {
 206                    title: "Cursor Shape",
 207                    description: "Cursor shape for the editor",
 208                    field: Box::new(SettingField {
 209                        pick: |settings_content| &settings_content.editor.cursor_shape,
 210                        pick_mut: |settings_content| &mut settings_content.editor.cursor_shape,
 211                    }),
 212                    metadata: None,
 213                }),
 214            ],
 215        },
 216    ]
 217}
 218
 219// Derive Macro, on the new ProjectSettings struct
 220
 221fn project_settings_data() -> Vec<SettingsPage> {
 222    vec![SettingsPage {
 223        title: "Project",
 224        expanded: true,
 225        items: vec![
 226            SettingsPageItem::SectionHeader("Worktree Settings Content"),
 227            SettingsPageItem::SettingItem(SettingItem {
 228                title: "Project Name",
 229                description: "The displayed name of this project. If not set, the root directory name",
 230                field: Box::new(SettingField {
 231                    pick: |settings_content| &settings_content.project.worktree.project_name,
 232                    pick_mut: |settings_content| {
 233                        &mut settings_content.project.worktree.project_name
 234                    },
 235                }),
 236                metadata: Some(Box::new(SettingsFieldMetadata {
 237                    placeholder: Some("A new name"),
 238                })),
 239            }),
 240        ],
 241    }]
 242}
 243
 244pub struct SettingsUiFeatureFlag;
 245
 246impl FeatureFlag for SettingsUiFeatureFlag {
 247    const NAME: &'static str = "settings-ui";
 248}
 249
 250actions!(
 251    zed,
 252    [
 253        /// Opens Settings Editor.
 254        OpenSettingsEditor
 255    ]
 256);
 257
 258pub fn init(cx: &mut App) {
 259    init_renderers(cx);
 260
 261    cx.observe_new(|workspace: &mut workspace::Workspace, _, _| {
 262        workspace.register_action_renderer(|div, _, _, cx| {
 263            let settings_ui_actions = [std::any::TypeId::of::<OpenSettingsEditor>()];
 264            let has_flag = cx.has_flag::<SettingsUiFeatureFlag>();
 265            command_palette_hooks::CommandPaletteFilter::update_global(cx, |filter, _| {
 266                if has_flag {
 267                    filter.show_action_types(&settings_ui_actions);
 268                } else {
 269                    filter.hide_action_types(&settings_ui_actions);
 270                }
 271            });
 272            if has_flag {
 273                div.on_action(cx.listener(|_, _: &OpenSettingsEditor, _, cx| {
 274                    open_settings_editor(cx).ok();
 275                }))
 276            } else {
 277                div
 278            }
 279        });
 280    })
 281    .detach();
 282}
 283
 284fn init_renderers(cx: &mut App) {
 285    // fn (field: SettingsField, current_file: SettingsFile, cx) -> (currently_set_in: SettingsFile, overridden_in: Vec<SettingsFile>)
 286    cx.default_global::<SettingFieldRenderer>()
 287        .add_renderer::<bool>(|settings_field, file, _, _, cx| {
 288            render_toggle_button(*settings_field, file, cx).into_any_element()
 289        })
 290        .add_renderer::<String>(|settings_field, file, metadata, _, cx| {
 291            render_text_field(settings_field.clone(), file, metadata, cx)
 292        })
 293        .add_renderer::<SaturatingBool>(|settings_field, file, _, _, cx| {
 294            render_toggle_button(*settings_field, file, cx)
 295        })
 296        .add_renderer::<CursorShape>(|settings_field, file, _, window, cx| {
 297            render_dropdown(*settings_field, file, window, cx)
 298        });
 299}
 300
 301pub fn open_settings_editor(cx: &mut App) -> anyhow::Result<WindowHandle<SettingsWindow>> {
 302    cx.open_window(
 303        WindowOptions {
 304            titlebar: Some(TitlebarOptions {
 305                title: Some("Settings Window".into()),
 306                appears_transparent: true,
 307                traffic_light_position: Some(point(px(12.0), px(12.0))),
 308            }),
 309            focus: true,
 310            show: true,
 311            kind: gpui::WindowKind::Normal,
 312            window_background: cx.theme().window_background_appearance(),
 313            window_min_size: Some(size(px(800.), px(600.))), // 4:3 Aspect Ratio
 314            ..Default::default()
 315        },
 316        |window, cx| cx.new(|cx| SettingsWindow::new(window, cx)),
 317    )
 318}
 319
 320pub struct SettingsWindow {
 321    files: Vec<SettingsUiFile>,
 322    current_file: SettingsUiFile,
 323    pages: Vec<SettingsPage>,
 324    search_bar: Entity<Editor>,
 325    search_task: Option<Task<()>>,
 326    navbar_entry: usize, // Index into pages - should probably be (usize, Option<usize>) for section + page
 327    navbar_entries: Vec<NavBarEntry>,
 328    list_handle: UniformListScrollHandle,
 329    search_matches: Vec<Vec<bool>>,
 330}
 331
 332#[derive(PartialEq, Debug)]
 333struct NavBarEntry {
 334    title: &'static str,
 335    is_root: bool,
 336}
 337
 338struct SettingsPage {
 339    title: &'static str,
 340    expanded: bool,
 341    items: Vec<SettingsPageItem>,
 342}
 343
 344#[derive(PartialEq)]
 345enum SettingsPageItem {
 346    SectionHeader(&'static str),
 347    SettingItem(SettingItem),
 348}
 349
 350impl std::fmt::Debug for SettingsPageItem {
 351    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
 352        match self {
 353            SettingsPageItem::SectionHeader(header) => write!(f, "SectionHeader({})", header),
 354            SettingsPageItem::SettingItem(setting_item) => {
 355                write!(f, "SettingItem({})", setting_item.title)
 356            }
 357        }
 358    }
 359}
 360
 361impl SettingsPageItem {
 362    fn render(&self, file: SettingsUiFile, window: &mut Window, cx: &mut App) -> AnyElement {
 363        match self {
 364            SettingsPageItem::SectionHeader(header) => v_flex()
 365                .w_full()
 366                .gap_0p5()
 367                .child(Label::new(SharedString::new_static(header)).size(LabelSize::Large))
 368                .child(Divider::horizontal().color(ui::DividerColor::BorderVariant))
 369                .into_any_element(),
 370            SettingsPageItem::SettingItem(setting_item) => {
 371                let renderer = cx.default_global::<SettingFieldRenderer>().clone();
 372                let file_set_in =
 373                    SettingsUiFile::from_settings(setting_item.field.file_set_in(file.clone(), cx));
 374
 375                h_flex()
 376                    .id(setting_item.title)
 377                    .w_full()
 378                    .gap_2()
 379                    .flex_wrap()
 380                    .justify_between()
 381                    .child(
 382                        v_flex()
 383                            .max_w_1_2()
 384                            .flex_shrink()
 385                            .child(
 386                                h_flex()
 387                                    .w_full()
 388                                    .gap_4()
 389                                    .child(
 390                                        Label::new(SharedString::new_static(setting_item.title))
 391                                            .size(LabelSize::Default),
 392                                    )
 393                                    .when_some(
 394                                        file_set_in.filter(|file_set_in| file_set_in != &file),
 395                                        |elem, file_set_in| {
 396                                            elem.child(
 397                                                Label::new(format!(
 398                                                    "set in {}",
 399                                                    file_set_in.name()
 400                                                ))
 401                                                .color(Color::Muted),
 402                                            )
 403                                        },
 404                                    ),
 405                            )
 406                            .child(
 407                                Label::new(SharedString::new_static(setting_item.description))
 408                                    .size(LabelSize::Small)
 409                                    .color(Color::Muted),
 410                            ),
 411                    )
 412                    .child(renderer.render(
 413                        setting_item.field.as_ref(),
 414                        file,
 415                        setting_item.metadata.as_deref(),
 416                        window,
 417                        cx,
 418                    ))
 419                    .into_any_element()
 420            }
 421        }
 422    }
 423}
 424
 425struct SettingItem {
 426    title: &'static str,
 427    description: &'static str,
 428    field: Box<dyn AnySettingField>,
 429    metadata: Option<Box<SettingsFieldMetadata>>,
 430}
 431
 432impl PartialEq for SettingItem {
 433    fn eq(&self, other: &Self) -> bool {
 434        self.title == other.title
 435            && self.description == other.description
 436            && (match (&self.metadata, &other.metadata) {
 437                (None, None) => true,
 438                (Some(m1), Some(m2)) => m1.placeholder == m2.placeholder,
 439                _ => false,
 440            })
 441    }
 442}
 443
 444#[allow(unused)]
 445#[derive(Clone, PartialEq)]
 446enum SettingsUiFile {
 447    User,                              // Uses all settings.
 448    Local((WorktreeId, Arc<RelPath>)), // Has a special name, and special set of settings
 449    Server(&'static str),              // Uses a special name, and the user settings
 450}
 451
 452impl SettingsUiFile {
 453    fn pages(&self) -> Vec<SettingsPage> {
 454        match self {
 455            SettingsUiFile::User => user_settings_data(),
 456            SettingsUiFile::Local(_) => project_settings_data(),
 457            SettingsUiFile::Server(_) => user_settings_data(),
 458        }
 459    }
 460
 461    fn name(&self) -> SharedString {
 462        match self {
 463            SettingsUiFile::User => SharedString::new_static("User"),
 464            // TODO is PathStyle::local() ever not appropriate?
 465            SettingsUiFile::Local((_, path)) => {
 466                format!("Local ({})", path.display(PathStyle::local())).into()
 467            }
 468            SettingsUiFile::Server(file) => format!("Server ({})", file).into(),
 469        }
 470    }
 471
 472    fn from_settings(file: settings::SettingsFile) -> Option<Self> {
 473        Some(match file {
 474            settings::SettingsFile::User => SettingsUiFile::User,
 475            settings::SettingsFile::Local(location) => SettingsUiFile::Local(location),
 476            settings::SettingsFile::Server => SettingsUiFile::Server("todo: server name"),
 477            settings::SettingsFile::Default => return None,
 478        })
 479    }
 480
 481    fn to_settings(&self) -> settings::SettingsFile {
 482        match self {
 483            SettingsUiFile::User => settings::SettingsFile::User,
 484            SettingsUiFile::Local(location) => settings::SettingsFile::Local(location.clone()),
 485            SettingsUiFile::Server(_) => settings::SettingsFile::Server,
 486        }
 487    }
 488}
 489
 490impl SettingsWindow {
 491    pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
 492        let current_file = SettingsUiFile::User;
 493        let search_bar = cx.new(|cx| {
 494            let mut editor = Editor::single_line(window, cx);
 495            editor.set_placeholder_text("Search settings…", window, cx);
 496            editor
 497        });
 498
 499        cx.subscribe(&search_bar, |this, _, event: &EditorEvent, cx| {
 500            let EditorEvent::Edited { transaction_id: _ } = event else {
 501                return;
 502            };
 503
 504            this.update_matches(cx);
 505        })
 506        .detach();
 507
 508        cx.observe_global_in::<SettingsStore>(window, move |this, _, cx| {
 509            this.fetch_files(cx);
 510            cx.notify();
 511        })
 512        .detach();
 513
 514        let mut this = Self {
 515            files: vec![],
 516            current_file: current_file,
 517            pages: vec![],
 518            navbar_entries: vec![],
 519            navbar_entry: 0,
 520            list_handle: UniformListScrollHandle::default(),
 521            search_bar,
 522            search_task: None,
 523            search_matches: vec![],
 524        };
 525
 526        this.fetch_files(cx);
 527        this.build_ui(cx);
 528
 529        this
 530    }
 531
 532    fn toggle_navbar_entry(&mut self, ix: usize) {
 533        // We can only toggle root entries
 534        if !self.navbar_entries[ix].is_root {
 535            return;
 536        }
 537
 538        let toggle_page_index = self.page_index_from_navbar_index(ix);
 539        let selected_page_index = self.page_index_from_navbar_index(self.navbar_entry);
 540
 541        let expanded = &mut self.page_for_navbar_index(ix).expanded;
 542        *expanded = !*expanded;
 543        let expanded = *expanded;
 544        // if currently selected page is a child of the parent page we are folding,
 545        // set the current page to the parent page
 546        if selected_page_index == toggle_page_index {
 547            self.navbar_entry = ix;
 548        } else if selected_page_index > toggle_page_index {
 549            let sub_items_count = self.pages[toggle_page_index]
 550                .items
 551                .iter()
 552                .filter(|item| matches!(item, SettingsPageItem::SectionHeader(_)))
 553                .count();
 554            if expanded {
 555                self.navbar_entry += sub_items_count;
 556            } else {
 557                self.navbar_entry -= sub_items_count;
 558            }
 559        }
 560
 561        self.build_navbar();
 562    }
 563
 564    fn build_navbar(&mut self) {
 565        let mut navbar_entries = Vec::with_capacity(self.navbar_entries.len());
 566        for (page_index, page) in self.pages.iter().enumerate() {
 567            if !self.search_matches[page_index]
 568                .iter()
 569                .any(|is_match| *is_match)
 570                && !self.search_matches[page_index].is_empty()
 571            {
 572                continue;
 573            }
 574            navbar_entries.push(NavBarEntry {
 575                title: page.title,
 576                is_root: true,
 577            });
 578            if !page.expanded {
 579                continue;
 580            }
 581
 582            for (item_index, item) in page.items.iter().enumerate() {
 583                let SettingsPageItem::SectionHeader(title) = item else {
 584                    continue;
 585                };
 586                if !self.search_matches[page_index][item_index] {
 587                    continue;
 588                }
 589
 590                navbar_entries.push(NavBarEntry {
 591                    title,
 592                    is_root: false,
 593                });
 594            }
 595        }
 596        self.navbar_entries = navbar_entries;
 597    }
 598
 599    fn update_matches(&mut self, cx: &mut Context<SettingsWindow>) {
 600        self.search_task.take();
 601        let query = self.search_bar.read(cx).text(cx);
 602        if query.is_empty() {
 603            for page in &mut self.search_matches {
 604                page.fill(true);
 605            }
 606            self.build_navbar();
 607            cx.notify();
 608            return;
 609        }
 610
 611        struct ItemKey {
 612            page_index: usize,
 613            header_index: usize,
 614            item_index: usize,
 615        }
 616        let mut key_lut: Vec<ItemKey> = vec![];
 617        let mut candidates = Vec::default();
 618
 619        for (page_index, page) in self.pages.iter().enumerate() {
 620            let mut header_index = 0;
 621            for (item_index, item) in page.items.iter().enumerate() {
 622                let key_index = key_lut.len();
 623                match item {
 624                    SettingsPageItem::SettingItem(item) => {
 625                        candidates.push(StringMatchCandidate::new(key_index, item.title));
 626                        candidates.push(StringMatchCandidate::new(key_index, item.description));
 627                    }
 628                    SettingsPageItem::SectionHeader(header) => {
 629                        candidates.push(StringMatchCandidate::new(key_index, header));
 630                        header_index = item_index;
 631                    }
 632                }
 633                key_lut.push(ItemKey {
 634                    page_index,
 635                    header_index,
 636                    item_index,
 637                });
 638            }
 639        }
 640        let atomic_bool = AtomicBool::new(false);
 641
 642        self.search_task = Some(cx.spawn(async move |this, cx| {
 643            let string_matches = fuzzy::match_strings(
 644                candidates.as_slice(),
 645                &query,
 646                false,
 647                false,
 648                candidates.len(),
 649                &atomic_bool,
 650                cx.background_executor().clone(),
 651            );
 652            let string_matches = string_matches.await;
 653
 654            this.update(cx, |this, cx| {
 655                for page in &mut this.search_matches {
 656                    page.fill(false);
 657                }
 658
 659                for string_match in string_matches {
 660                    let ItemKey {
 661                        page_index,
 662                        header_index,
 663                        item_index,
 664                    } = key_lut[string_match.candidate_id];
 665                    let page = &mut this.search_matches[page_index];
 666                    page[header_index] = true;
 667                    page[item_index] = true;
 668                }
 669                this.build_navbar();
 670                this.navbar_entry = 0;
 671                cx.notify()
 672            })
 673            .ok();
 674        }));
 675    }
 676
 677    fn build_ui(&mut self, cx: &mut Context<SettingsWindow>) {
 678        self.pages = self.current_file.pages();
 679        self.search_matches = self
 680            .pages
 681            .iter()
 682            .map(|page| vec![true; page.items.len()])
 683            .collect::<Vec<_>>();
 684        self.build_navbar();
 685
 686        if !self.search_bar.read(cx).is_empty(cx) {
 687            self.update_matches(cx);
 688        }
 689
 690        cx.notify();
 691    }
 692
 693    fn fetch_files(&mut self, cx: &mut Context<SettingsWindow>) {
 694        let settings_store = cx.global::<SettingsStore>();
 695        let mut ui_files = vec![];
 696        let all_files = settings_store.get_all_files();
 697        for file in all_files {
 698            let Some(settings_ui_file) = SettingsUiFile::from_settings(file) else {
 699                continue;
 700            };
 701            ui_files.push(settings_ui_file);
 702        }
 703        ui_files.reverse();
 704        self.files = ui_files;
 705        if !self.files.contains(&self.current_file) {
 706            self.change_file(0, cx);
 707        }
 708    }
 709
 710    fn change_file(&mut self, ix: usize, cx: &mut Context<SettingsWindow>) {
 711        if ix >= self.files.len() {
 712            self.current_file = SettingsUiFile::User;
 713            return;
 714        }
 715        if self.files[ix] == self.current_file {
 716            return;
 717        }
 718        self.current_file = self.files[ix].clone();
 719        self.build_ui(cx);
 720    }
 721
 722    fn render_files(&self, _window: &mut Window, cx: &mut Context<SettingsWindow>) -> Div {
 723        h_flex()
 724            .gap_1()
 725            .children(self.files.iter().enumerate().map(|(ix, file)| {
 726                Button::new(ix, file.name())
 727                    .on_click(cx.listener(move |this, _, _window, cx| this.change_file(ix, cx)))
 728            }))
 729    }
 730
 731    fn render_search(&self, _window: &mut Window, cx: &mut App) -> Div {
 732        h_flex()
 733            .pt_1()
 734            .px_1p5()
 735            .gap_1p5()
 736            .rounded_sm()
 737            .bg(cx.theme().colors().editor_background)
 738            .border_1()
 739            .border_color(cx.theme().colors().border)
 740            .child(Icon::new(IconName::MagnifyingGlass).color(Color::Muted))
 741            .child(self.search_bar.clone())
 742    }
 743
 744    fn render_nav(&self, window: &mut Window, cx: &mut Context<SettingsWindow>) -> Div {
 745        v_flex()
 746            .w_64()
 747            .p_2p5()
 748            .pt_10()
 749            .gap_3()
 750            .flex_none()
 751            .border_r_1()
 752            .border_color(cx.theme().colors().border)
 753            .bg(cx.theme().colors().panel_background)
 754            .child(self.render_search(window, cx).pb_1())
 755            .child(
 756                uniform_list(
 757                    "settings-ui-nav-bar",
 758                    self.navbar_entries.len(),
 759                    cx.processor(|this, range: Range<usize>, _, cx| {
 760                        range
 761                            .into_iter()
 762                            .map(|ix| {
 763                                let entry = &this.navbar_entries[ix];
 764
 765                                h_flex()
 766                                    .id(("settings-ui-section", ix))
 767                                    .w_full()
 768                                    .pl_2p5()
 769                                    .py_0p5()
 770                                    .rounded_sm()
 771                                    .border_1()
 772                                    .border_color(cx.theme().colors().border_transparent)
 773                                    .text_color(cx.theme().colors().text_muted)
 774                                    .when(this.is_navbar_entry_selected(ix), |this| {
 775                                        this.text_color(cx.theme().colors().text)
 776                                            .bg(cx.theme().colors().element_selected.opacity(0.2))
 777                                            .border_color(cx.theme().colors().border)
 778                                    })
 779                                    .child(
 780                                        ListItem::new(("settings-ui-navbar-entry", ix))
 781                                            .selectable(true)
 782                                            .inset(true)
 783                                            .indent_step_size(px(1.))
 784                                            .indent_level(if entry.is_root { 1 } else { 3 })
 785                                            .when(entry.is_root, |item| {
 786                                                item.toggle(
 787                                                    this.pages
 788                                                        [this.page_index_from_navbar_index(ix)]
 789                                                    .expanded,
 790                                                )
 791                                                .always_show_disclosure_icon(true)
 792                                                .on_toggle(cx.listener(move |this, _, _, cx| {
 793                                                    this.toggle_navbar_entry(ix);
 794                                                    cx.notify();
 795                                                }))
 796                                            })
 797                                            .child(
 798                                                h_flex()
 799                                                    .text_ui(cx)
 800                                                    .truncate()
 801                                                    .hover(|s| {
 802                                                        s.bg(cx.theme().colors().element_hover)
 803                                                    })
 804                                                    .child(entry.title),
 805                                            ),
 806                                    )
 807                                    .on_click(cx.listener(move |this, _, _, cx| {
 808                                        this.navbar_entry = ix;
 809                                        cx.notify();
 810                                    }))
 811                            })
 812                            .collect()
 813                    }),
 814                )
 815                .track_scroll(self.list_handle.clone())
 816                .size_full()
 817                .flex_grow(),
 818            )
 819    }
 820
 821    fn page_items(&self) -> impl Iterator<Item = &SettingsPageItem> {
 822        let page_idx = self.current_page_index();
 823
 824        self.current_page()
 825            .items
 826            .iter()
 827            .enumerate()
 828            .filter_map(move |(item_index, item)| {
 829                self.search_matches[page_idx][item_index].then_some(item)
 830            })
 831    }
 832
 833    fn render_page(&self, window: &mut Window, cx: &mut Context<SettingsWindow>) -> Div {
 834        v_flex().gap_4().children(
 835            self.page_items()
 836                .map(|item| item.render(self.current_file.clone(), window, cx)),
 837        )
 838    }
 839
 840    fn current_page_index(&self) -> usize {
 841        self.page_index_from_navbar_index(self.navbar_entry)
 842    }
 843
 844    fn current_page(&self) -> &SettingsPage {
 845        &self.pages[self.current_page_index()]
 846    }
 847
 848    fn page_index_from_navbar_index(&self, index: usize) -> usize {
 849        if self.navbar_entries.is_empty() {
 850            return 0;
 851        }
 852
 853        self.navbar_entries
 854            .iter()
 855            .take(index + 1)
 856            .map(|entry| entry.is_root as usize)
 857            .sum::<usize>()
 858            - 1
 859    }
 860
 861    fn page_for_navbar_index(&mut self, index: usize) -> &mut SettingsPage {
 862        let index = self.page_index_from_navbar_index(index);
 863        &mut self.pages[index]
 864    }
 865
 866    fn is_navbar_entry_selected(&self, ix: usize) -> bool {
 867        ix == self.navbar_entry
 868    }
 869}
 870
 871impl Render for SettingsWindow {
 872    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
 873        div()
 874            .flex()
 875            .flex_row()
 876            .size_full()
 877            .bg(cx.theme().colors().background)
 878            .text_color(cx.theme().colors().text)
 879            .child(self.render_nav(window, cx))
 880            .child(
 881                v_flex()
 882                    .w_full()
 883                    .pt_4()
 884                    .px_6()
 885                    .gap_4()
 886                    .bg(cx.theme().colors().editor_background)
 887                    .child(self.render_files(window, cx))
 888                    .child(self.render_page(window, cx)),
 889            )
 890    }
 891}
 892
 893// fn read_field<T>(pick: fn(&SettingsContent) -> &Option<T>, file: SettingsFile, cx: &App) -> Option<T> {
 894//     let (_, value) = cx.global::<SettingsStore>().get_value_from_file(file.to_settings(), (), pick);
 895// }
 896
 897fn render_text_field(
 898    field: SettingField<String>,
 899    file: SettingsUiFile,
 900    metadata: Option<&SettingsFieldMetadata>,
 901    cx: &mut App,
 902) -> AnyElement {
 903    let (_, initial_text) =
 904        SettingsStore::global(cx).get_value_from_file(file.to_settings(), field.pick);
 905    let initial_text = Some(initial_text.clone()).filter(|s| !s.is_empty());
 906
 907    SettingsEditor::new()
 908        .when_some(initial_text, |editor, text| editor.with_initial_text(text))
 909        .when_some(
 910            metadata.and_then(|metadata| metadata.placeholder),
 911            |editor, placeholder| editor.with_placeholder(placeholder),
 912        )
 913        .on_confirm(move |new_text, cx: &mut App| {
 914            cx.update_global(move |store: &mut SettingsStore, cx| {
 915                store.update_settings_file(<dyn fs::Fs>::global(cx), move |settings, _cx| {
 916                    *(field.pick_mut)(settings) = new_text;
 917                });
 918            });
 919        })
 920        .into_any_element()
 921}
 922
 923fn render_toggle_button<B: Into<bool> + From<bool> + Copy>(
 924    field: SettingField<B>,
 925    file: SettingsUiFile,
 926    cx: &mut App,
 927) -> AnyElement {
 928    let (_, &value) = SettingsStore::global(cx).get_value_from_file(file.to_settings(), field.pick);
 929
 930    let toggle_state = if value.into() {
 931        ui::ToggleState::Selected
 932    } else {
 933        ui::ToggleState::Unselected
 934    };
 935
 936    Switch::new("toggle_button", toggle_state)
 937        .on_click({
 938            move |state, _window, cx| {
 939                let state = *state == ui::ToggleState::Selected;
 940                let field = field;
 941                cx.update_global(move |store: &mut SettingsStore, cx| {
 942                    store.update_settings_file(<dyn fs::Fs>::global(cx), move |settings, _cx| {
 943                        *(field.pick_mut)(settings) = Some(state.into());
 944                    });
 945                });
 946            }
 947        })
 948        .into_any_element()
 949}
 950
 951fn render_dropdown<T>(
 952    field: SettingField<T>,
 953    file: SettingsUiFile,
 954    window: &mut Window,
 955    cx: &mut App,
 956) -> AnyElement
 957where
 958    T: strum::VariantArray + strum::VariantNames + Copy + PartialEq + Send + 'static,
 959{
 960    let variants = || -> &'static [T] { <T as strum::VariantArray>::VARIANTS };
 961    let labels = || -> &'static [&'static str] { <T as strum::VariantNames>::VARIANTS };
 962
 963    let (_, &current_value) =
 964        SettingsStore::global(cx).get_value_from_file(file.to_settings(), field.pick);
 965
 966    let current_value_label =
 967        labels()[variants().iter().position(|v| *v == current_value).unwrap()];
 968
 969    DropdownMenu::new(
 970        "dropdown",
 971        current_value_label,
 972        ui::ContextMenu::build(window, cx, move |mut menu, _, _| {
 973            for (value, label) in variants()
 974                .into_iter()
 975                .copied()
 976                .zip(labels().into_iter().copied())
 977            {
 978                menu = menu.toggleable_entry(
 979                    label,
 980                    value == current_value,
 981                    ui::IconPosition::Start,
 982                    None,
 983                    move |_, cx| {
 984                        if value == current_value {
 985                            return;
 986                        }
 987                        cx.update_global(move |store: &mut SettingsStore, cx| {
 988                            store.update_settings_file(
 989                                <dyn fs::Fs>::global(cx),
 990                                move |settings, _cx| {
 991                                    *(field.pick_mut)(settings) = Some(value);
 992                                },
 993                            );
 994                        });
 995                    },
 996                );
 997            }
 998            menu
 999        }),
1000    )
1001    .into_any_element()
1002}
1003
1004#[cfg(test)]
1005mod test {
1006
1007    use super::*;
1008
1009    impl SettingsWindow {
1010        fn navbar(&self) -> &[NavBarEntry] {
1011            self.navbar_entries.as_slice()
1012        }
1013
1014        fn navbar_entry(&self) -> usize {
1015            self.navbar_entry
1016        }
1017
1018        fn new_builder(window: &mut Window, cx: &mut Context<Self>) -> Self {
1019            let mut this = Self::new(window, cx);
1020            this.navbar_entries.clear();
1021            this.pages.clear();
1022            this
1023        }
1024
1025        fn build(mut self) -> Self {
1026            self.build_navbar();
1027            self
1028        }
1029
1030        fn add_page(
1031            mut self,
1032            title: &'static str,
1033            build_page: impl Fn(SettingsPage) -> SettingsPage,
1034        ) -> Self {
1035            let page = SettingsPage {
1036                title,
1037                expanded: false,
1038                items: Vec::default(),
1039            };
1040
1041            self.pages.push(build_page(page));
1042            self
1043        }
1044
1045        fn search(&mut self, search_query: &str, window: &mut Window, cx: &mut Context<Self>) {
1046            self.search_task.take();
1047            self.search_bar.update(cx, |editor, cx| {
1048                editor.set_text(search_query, window, cx);
1049            });
1050            self.update_matches(cx);
1051        }
1052
1053        fn assert_search_results(&self, other: &Self) {
1054            assert_eq!(self.navbar_entries, other.navbar_entries);
1055            assert_eq!(
1056                self.current_page().items.iter().collect::<Vec<_>>(),
1057                other.page_items().collect::<Vec<_>>()
1058            );
1059        }
1060    }
1061
1062    impl SettingsPage {
1063        fn item(mut self, item: SettingsPageItem) -> Self {
1064            self.items.push(item);
1065            self
1066        }
1067    }
1068
1069    fn register_settings(cx: &mut App) {
1070        settings::init(cx);
1071        theme::init(theme::LoadThemes::JustBase, cx);
1072        workspace::init_settings(cx);
1073        project::Project::init_settings(cx);
1074        language::init(cx);
1075        editor::init(cx);
1076        menu::init();
1077    }
1078
1079    fn parse(input: &'static str, window: &mut Window, cx: &mut App) -> SettingsWindow {
1080        let mut pages: Vec<SettingsPage> = Vec::new();
1081        let mut current_page = None;
1082        let mut selected_idx = None;
1083        let mut ix = 0;
1084        let mut in_closed_subentry = false;
1085
1086        for mut line in input
1087            .lines()
1088            .map(|line| line.trim())
1089            .filter(|line| !line.is_empty())
1090        {
1091            let mut is_selected = false;
1092            if line.ends_with("*") {
1093                assert!(
1094                    selected_idx.is_none(),
1095                    "Can only have one selected navbar entry at a time"
1096                );
1097                selected_idx = Some(ix);
1098                line = &line[..line.len() - 1];
1099                is_selected = true;
1100            }
1101
1102            if line.starts_with("v") || line.starts_with(">") {
1103                if let Some(current_page) = current_page.take() {
1104                    pages.push(current_page);
1105                }
1106
1107                let expanded = line.starts_with("v");
1108                in_closed_subentry = !expanded;
1109                ix += 1;
1110
1111                current_page = Some(SettingsPage {
1112                    title: line.split_once(" ").unwrap().1,
1113                    expanded,
1114                    items: Vec::default(),
1115                });
1116            } else if line.starts_with("-") {
1117                if !in_closed_subentry {
1118                    ix += 1;
1119                } else if is_selected && in_closed_subentry {
1120                    panic!("Can't select sub entry if it's parent is closed");
1121                }
1122
1123                let Some(current_page) = current_page.as_mut() else {
1124                    panic!("Sub entries must be within a page");
1125                };
1126
1127                current_page.items.push(SettingsPageItem::SectionHeader(
1128                    line.split_once(" ").unwrap().1,
1129                ));
1130            } else {
1131                panic!(
1132                    "Entries must start with one of 'v', '>', or '-'\n line: {}",
1133                    line
1134                );
1135            }
1136        }
1137
1138        if let Some(current_page) = current_page.take() {
1139            pages.push(current_page);
1140        }
1141
1142        let search_matches = pages
1143            .iter()
1144            .map(|page| vec![true; page.items.len()])
1145            .collect::<Vec<_>>();
1146
1147        let mut settings_window = SettingsWindow {
1148            files: Vec::default(),
1149            current_file: crate::SettingsUiFile::User,
1150            pages,
1151            search_bar: cx.new(|cx| Editor::single_line(window, cx)),
1152            navbar_entry: selected_idx.expect("Must have a selected navbar entry"),
1153            navbar_entries: Vec::default(),
1154            list_handle: UniformListScrollHandle::default(),
1155            search_matches,
1156            search_task: None,
1157        };
1158
1159        settings_window.build_navbar();
1160        settings_window
1161    }
1162
1163    #[track_caller]
1164    fn check_navbar_toggle(
1165        before: &'static str,
1166        toggle_idx: usize,
1167        after: &'static str,
1168        window: &mut Window,
1169        cx: &mut App,
1170    ) {
1171        let mut settings_window = parse(before, window, cx);
1172        settings_window.toggle_navbar_entry(toggle_idx);
1173
1174        let expected_settings_window = parse(after, window, cx);
1175
1176        assert_eq!(settings_window.navbar(), expected_settings_window.navbar());
1177        assert_eq!(
1178            settings_window.navbar_entry(),
1179            expected_settings_window.navbar_entry()
1180        );
1181    }
1182
1183    macro_rules! check_navbar_toggle {
1184        ($name:ident, before: $before:expr, toggle_idx: $toggle_idx:expr, after: $after:expr) => {
1185            #[gpui::test]
1186            fn $name(cx: &mut gpui::TestAppContext) {
1187                let window = cx.add_empty_window();
1188                window.update(|window, cx| {
1189                    register_settings(cx);
1190                    check_navbar_toggle($before, $toggle_idx, $after, window, cx);
1191                });
1192            }
1193        };
1194    }
1195
1196    check_navbar_toggle!(
1197        navbar_basic_open,
1198        before: r"
1199        v General
1200        - General
1201        - Privacy*
1202        v Project
1203        - Project Settings
1204        ",
1205        toggle_idx: 0,
1206        after: r"
1207        > General*
1208        v Project
1209        - Project Settings
1210        "
1211    );
1212
1213    check_navbar_toggle!(
1214        navbar_basic_close,
1215        before: r"
1216        > General*
1217        - General
1218        - Privacy
1219        v Project
1220        - Project Settings
1221        ",
1222        toggle_idx: 0,
1223        after: r"
1224        v General*
1225        - General
1226        - Privacy
1227        v Project
1228        - Project Settings
1229        "
1230    );
1231
1232    check_navbar_toggle!(
1233        navbar_basic_second_root_entry_close,
1234        before: r"
1235        > General
1236        - General
1237        - Privacy
1238        v Project
1239        - Project Settings*
1240        ",
1241        toggle_idx: 1,
1242        after: r"
1243        > General
1244        > Project*
1245        "
1246    );
1247
1248    check_navbar_toggle!(
1249        navbar_toggle_subroot,
1250        before: r"
1251        v General Page
1252        - General
1253        - Privacy
1254        v Project
1255        - Worktree Settings Content*
1256        v AI
1257        - General
1258        > Appearance & Behavior
1259        ",
1260        toggle_idx: 3,
1261        after: r"
1262        v General Page
1263        - General
1264        - Privacy
1265        > Project*
1266        v AI
1267        - General
1268        > Appearance & Behavior
1269        "
1270    );
1271
1272    check_navbar_toggle!(
1273        navbar_toggle_close_propagates_selected_index,
1274        before: r"
1275        v General Page
1276        - General
1277        - Privacy
1278        v Project
1279        - Worktree Settings Content
1280        v AI
1281        - General*
1282        > Appearance & Behavior
1283        ",
1284        toggle_idx: 0,
1285        after: r"
1286        > General Page
1287        v Project
1288        - Worktree Settings Content
1289        v AI
1290        - General*
1291        > Appearance & Behavior
1292        "
1293    );
1294
1295    check_navbar_toggle!(
1296        navbar_toggle_expand_propagates_selected_index,
1297        before: r"
1298        > General Page
1299        - General
1300        - Privacy
1301        v Project
1302        - Worktree Settings Content
1303        v AI
1304        - General*
1305        > Appearance & Behavior
1306        ",
1307        toggle_idx: 0,
1308        after: r"
1309        v General Page
1310        - General
1311        - Privacy
1312        v Project
1313        - Worktree Settings Content
1314        v AI
1315        - General*
1316        > Appearance & Behavior
1317        "
1318    );
1319
1320    check_navbar_toggle!(
1321        navbar_toggle_sub_entry_does_nothing,
1322        before: r"
1323        > General Page
1324        - General
1325        - Privacy
1326        v Project
1327        - Worktree Settings Content
1328        v AI
1329        - General*
1330        > Appearance & Behavior
1331        ",
1332        toggle_idx: 4,
1333        after: r"
1334        > General Page
1335        - General
1336        - Privacy
1337        v Project
1338        - Worktree Settings Content
1339        v AI
1340        - General*
1341        > Appearance & Behavior
1342        "
1343    );
1344
1345    #[gpui::test]
1346    fn test_basic_search(cx: &mut gpui::TestAppContext) {
1347        let cx = cx.add_empty_window();
1348        let (actual, expected) = cx.update(|window, cx| {
1349            register_settings(cx);
1350
1351            let expected = cx.new(|cx| {
1352                SettingsWindow::new_builder(window, cx)
1353                    .add_page("General", |page| {
1354                        page.item(SettingsPageItem::SectionHeader("General settings"))
1355                            .item(SettingsPageItem::SettingItem(SettingItem {
1356                                title: "test title",
1357                                description: "General test",
1358                                field: Box::new(SettingField {
1359                                    pick: |settings_content| {
1360                                        &settings_content.workspace.confirm_quit
1361                                    },
1362                                    pick_mut: |settings_content| {
1363                                        &mut settings_content.workspace.confirm_quit
1364                                    },
1365                                }),
1366                                metadata: None,
1367                            }))
1368                    })
1369                    .build()
1370            });
1371
1372            let actual = cx.new(|cx| {
1373                SettingsWindow::new_builder(window, cx)
1374                    .add_page("General", |page| {
1375                        page.item(SettingsPageItem::SectionHeader("General settings"))
1376                            .item(SettingsPageItem::SettingItem(SettingItem {
1377                                title: "test title",
1378                                description: "General test",
1379                                field: Box::new(SettingField {
1380                                    pick: |settings_content| {
1381                                        &settings_content.workspace.confirm_quit
1382                                    },
1383                                    pick_mut: |settings_content| {
1384                                        &mut settings_content.workspace.confirm_quit
1385                                    },
1386                                }),
1387                                metadata: None,
1388                            }))
1389                    })
1390                    .add_page("Theme", |page| {
1391                        page.item(SettingsPageItem::SectionHeader("Theme settings"))
1392                    })
1393                    .build()
1394            });
1395
1396            actual.update(cx, |settings, cx| settings.search("gen", window, cx));
1397
1398            (actual, expected)
1399        });
1400
1401        cx.cx.run_until_parked();
1402
1403        cx.update(|_window, cx| {
1404            let expected = expected.read(cx);
1405            let actual = actual.read(cx);
1406            expected.assert_search_results(&actual);
1407        })
1408    }
1409}