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