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    page_index: usize,
 337}
 338
 339struct SettingsPage {
 340    title: &'static str,
 341    expanded: bool,
 342    items: Vec<SettingsPageItem>,
 343}
 344
 345#[derive(PartialEq)]
 346enum SettingsPageItem {
 347    SectionHeader(&'static str),
 348    SettingItem(SettingItem),
 349}
 350
 351impl std::fmt::Debug for SettingsPageItem {
 352    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
 353        match self {
 354            SettingsPageItem::SectionHeader(header) => write!(f, "SectionHeader({})", header),
 355            SettingsPageItem::SettingItem(setting_item) => {
 356                write!(f, "SettingItem({})", setting_item.title)
 357            }
 358        }
 359    }
 360}
 361
 362impl SettingsPageItem {
 363    fn render(&self, file: SettingsUiFile, window: &mut Window, cx: &mut App) -> AnyElement {
 364        match self {
 365            SettingsPageItem::SectionHeader(header) => v_flex()
 366                .w_full()
 367                .gap_0p5()
 368                .child(Label::new(SharedString::new_static(header)).size(LabelSize::Large))
 369                .child(Divider::horizontal().color(ui::DividerColor::BorderVariant))
 370                .into_any_element(),
 371            SettingsPageItem::SettingItem(setting_item) => {
 372                let renderer = cx.default_global::<SettingFieldRenderer>().clone();
 373                let file_set_in =
 374                    SettingsUiFile::from_settings(setting_item.field.file_set_in(file.clone(), cx));
 375
 376                h_flex()
 377                    .id(setting_item.title)
 378                    .w_full()
 379                    .gap_2()
 380                    .flex_wrap()
 381                    .justify_between()
 382                    .child(
 383                        v_flex()
 384                            .max_w_1_2()
 385                            .flex_shrink()
 386                            .child(
 387                                h_flex()
 388                                    .w_full()
 389                                    .gap_4()
 390                                    .child(
 391                                        Label::new(SharedString::new_static(setting_item.title))
 392                                            .size(LabelSize::Default),
 393                                    )
 394                                    .when_some(
 395                                        file_set_in.filter(|file_set_in| file_set_in != &file),
 396                                        |elem, file_set_in| {
 397                                            elem.child(
 398                                                Label::new(format!(
 399                                                    "set in {}",
 400                                                    file_set_in.name()
 401                                                ))
 402                                                .color(Color::Muted),
 403                                            )
 404                                        },
 405                                    ),
 406                            )
 407                            .child(
 408                                Label::new(SharedString::new_static(setting_item.description))
 409                                    .size(LabelSize::Small)
 410                                    .color(Color::Muted),
 411                            ),
 412                    )
 413                    .child(renderer.render(
 414                        setting_item.field.as_ref(),
 415                        file,
 416                        setting_item.metadata.as_deref(),
 417                        window,
 418                        cx,
 419                    ))
 420                    .into_any_element()
 421            }
 422        }
 423    }
 424}
 425
 426struct SettingItem {
 427    title: &'static str,
 428    description: &'static str,
 429    field: Box<dyn AnySettingField>,
 430    metadata: Option<Box<SettingsFieldMetadata>>,
 431}
 432
 433impl PartialEq for SettingItem {
 434    fn eq(&self, other: &Self) -> bool {
 435        self.title == other.title
 436            && self.description == other.description
 437            && (match (&self.metadata, &other.metadata) {
 438                (None, None) => true,
 439                (Some(m1), Some(m2)) => m1.placeholder == m2.placeholder,
 440                _ => false,
 441            })
 442    }
 443}
 444
 445#[allow(unused)]
 446#[derive(Clone, PartialEq)]
 447enum SettingsUiFile {
 448    User,                              // Uses all settings.
 449    Local((WorktreeId, Arc<RelPath>)), // Has a special name, and special set of settings
 450    Server(&'static str),              // Uses a special name, and the user settings
 451}
 452
 453impl SettingsUiFile {
 454    fn pages(&self) -> Vec<SettingsPage> {
 455        match self {
 456            SettingsUiFile::User => user_settings_data(),
 457            SettingsUiFile::Local(_) => project_settings_data(),
 458            SettingsUiFile::Server(_) => user_settings_data(),
 459        }
 460    }
 461
 462    fn name(&self) -> SharedString {
 463        match self {
 464            SettingsUiFile::User => SharedString::new_static("User"),
 465            // TODO is PathStyle::local() ever not appropriate?
 466            SettingsUiFile::Local((_, path)) => {
 467                format!("Local ({})", path.display(PathStyle::local())).into()
 468            }
 469            SettingsUiFile::Server(file) => format!("Server ({})", file).into(),
 470        }
 471    }
 472
 473    fn from_settings(file: settings::SettingsFile) -> Option<Self> {
 474        Some(match file {
 475            settings::SettingsFile::User => SettingsUiFile::User,
 476            settings::SettingsFile::Local(location) => SettingsUiFile::Local(location),
 477            settings::SettingsFile::Server => SettingsUiFile::Server("todo: server name"),
 478            settings::SettingsFile::Default => return None,
 479        })
 480    }
 481
 482    fn to_settings(&self) -> settings::SettingsFile {
 483        match self {
 484            SettingsUiFile::User => settings::SettingsFile::User,
 485            SettingsUiFile::Local(location) => settings::SettingsFile::Local(location.clone()),
 486            SettingsUiFile::Server(_) => settings::SettingsFile::Server,
 487        }
 488    }
 489}
 490
 491impl SettingsWindow {
 492    pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
 493        let current_file = SettingsUiFile::User;
 494        let search_bar = cx.new(|cx| {
 495            let mut editor = Editor::single_line(window, cx);
 496            editor.set_placeholder_text("Search settings…", window, cx);
 497            editor
 498        });
 499
 500        cx.subscribe(&search_bar, |this, _, event: &EditorEvent, cx| {
 501            let EditorEvent::Edited { transaction_id: _ } = event else {
 502                return;
 503            };
 504
 505            this.update_matches(cx);
 506        })
 507        .detach();
 508
 509        cx.observe_global_in::<SettingsStore>(window, move |this, _, cx| {
 510            this.fetch_files(cx);
 511            cx.notify();
 512        })
 513        .detach();
 514
 515        let mut this = Self {
 516            files: vec![],
 517            current_file: current_file,
 518            pages: vec![],
 519            navbar_entries: vec![],
 520            navbar_entry: 0,
 521            list_handle: UniformListScrollHandle::default(),
 522            search_bar,
 523            search_task: None,
 524            search_matches: vec![],
 525        };
 526
 527        this.fetch_files(cx);
 528        this.build_ui(cx);
 529
 530        this
 531    }
 532
 533    fn toggle_navbar_entry(&mut self, ix: usize) {
 534        // We can only toggle root entries
 535        if !self.navbar_entries[ix].is_root {
 536            return;
 537        }
 538
 539        let toggle_page_index = self.page_index_from_navbar_index(ix);
 540        let selected_page_index = self.page_index_from_navbar_index(self.navbar_entry);
 541
 542        let expanded = &mut self.page_for_navbar_index(ix).expanded;
 543        *expanded = !*expanded;
 544        let expanded = *expanded;
 545        // if currently selected page is a child of the parent page we are folding,
 546        // set the current page to the parent page
 547        if selected_page_index == toggle_page_index {
 548            self.navbar_entry = ix;
 549        } else if selected_page_index > toggle_page_index {
 550            let sub_items_count = self.pages[toggle_page_index]
 551                .items
 552                .iter()
 553                .filter(|item| matches!(item, SettingsPageItem::SectionHeader(_)))
 554                .count();
 555            if expanded {
 556                self.navbar_entry += sub_items_count;
 557            } else {
 558                self.navbar_entry -= sub_items_count;
 559            }
 560        }
 561
 562        self.build_navbar();
 563    }
 564
 565    fn build_navbar(&mut self) {
 566        let mut navbar_entries = Vec::with_capacity(self.navbar_entries.len());
 567        for (page_index, page) in self.pages.iter().enumerate() {
 568            if !self.search_matches[page_index]
 569                .iter()
 570                .any(|is_match| *is_match)
 571                && !self.search_matches[page_index].is_empty()
 572            {
 573                continue;
 574            }
 575            navbar_entries.push(NavBarEntry {
 576                title: page.title,
 577                is_root: true,
 578                page_index,
 579            });
 580            if !page.expanded {
 581                continue;
 582            }
 583
 584            for (item_index, item) in page.items.iter().enumerate() {
 585                let SettingsPageItem::SectionHeader(title) = item else {
 586                    continue;
 587                };
 588                if !self.search_matches[page_index][item_index] {
 589                    continue;
 590                }
 591
 592                navbar_entries.push(NavBarEntry {
 593                    title,
 594                    is_root: false,
 595                    page_index,
 596                });
 597            }
 598        }
 599        self.navbar_entries = navbar_entries;
 600    }
 601
 602    fn update_matches(&mut self, cx: &mut Context<SettingsWindow>) {
 603        self.search_task.take();
 604        let query = self.search_bar.read(cx).text(cx);
 605        if query.is_empty() {
 606            for page in &mut self.search_matches {
 607                page.fill(true);
 608            }
 609            self.build_navbar();
 610            cx.notify();
 611            return;
 612        }
 613
 614        struct ItemKey {
 615            page_index: usize,
 616            header_index: usize,
 617            item_index: usize,
 618        }
 619        let mut key_lut: Vec<ItemKey> = vec![];
 620        let mut candidates = Vec::default();
 621
 622        for (page_index, page) in self.pages.iter().enumerate() {
 623            let mut header_index = 0;
 624            for (item_index, item) in page.items.iter().enumerate() {
 625                let key_index = key_lut.len();
 626                match item {
 627                    SettingsPageItem::SettingItem(item) => {
 628                        candidates.push(StringMatchCandidate::new(key_index, item.title));
 629                        candidates.push(StringMatchCandidate::new(key_index, item.description));
 630                    }
 631                    SettingsPageItem::SectionHeader(header) => {
 632                        candidates.push(StringMatchCandidate::new(key_index, header));
 633                        header_index = item_index;
 634                    }
 635                }
 636                key_lut.push(ItemKey {
 637                    page_index,
 638                    header_index,
 639                    item_index,
 640                });
 641            }
 642        }
 643        let atomic_bool = AtomicBool::new(false);
 644
 645        self.search_task = Some(cx.spawn(async move |this, cx| {
 646            let string_matches = fuzzy::match_strings(
 647                candidates.as_slice(),
 648                &query,
 649                false,
 650                false,
 651                candidates.len(),
 652                &atomic_bool,
 653                cx.background_executor().clone(),
 654            );
 655            let string_matches = string_matches.await;
 656
 657            this.update(cx, |this, cx| {
 658                for page in &mut this.search_matches {
 659                    page.fill(false);
 660                }
 661
 662                for string_match in string_matches {
 663                    let ItemKey {
 664                        page_index,
 665                        header_index,
 666                        item_index,
 667                    } = key_lut[string_match.candidate_id];
 668                    let page = &mut this.search_matches[page_index];
 669                    page[header_index] = true;
 670                    page[item_index] = true;
 671                }
 672                this.build_navbar();
 673                this.navbar_entry = 0;
 674                cx.notify();
 675            })
 676            .ok();
 677        }));
 678    }
 679
 680    fn build_ui(&mut self, cx: &mut Context<SettingsWindow>) {
 681        self.pages = self.current_file.pages();
 682        self.search_matches = self
 683            .pages
 684            .iter()
 685            .map(|page| vec![true; page.items.len()])
 686            .collect::<Vec<_>>();
 687        self.build_navbar();
 688
 689        if !self.search_bar.read(cx).is_empty(cx) {
 690            self.update_matches(cx);
 691        }
 692
 693        cx.notify();
 694    }
 695
 696    fn fetch_files(&mut self, cx: &mut Context<SettingsWindow>) {
 697        let settings_store = cx.global::<SettingsStore>();
 698        let mut ui_files = vec![];
 699        let all_files = settings_store.get_all_files();
 700        for file in all_files {
 701            let Some(settings_ui_file) = SettingsUiFile::from_settings(file) else {
 702                continue;
 703            };
 704            ui_files.push(settings_ui_file);
 705        }
 706        ui_files.reverse();
 707        self.files = ui_files;
 708        if !self.files.contains(&self.current_file) {
 709            self.change_file(0, cx);
 710        }
 711    }
 712
 713    fn change_file(&mut self, ix: usize, cx: &mut Context<SettingsWindow>) {
 714        if ix >= self.files.len() {
 715            self.current_file = SettingsUiFile::User;
 716            return;
 717        }
 718        if self.files[ix] == self.current_file {
 719            return;
 720        }
 721        self.current_file = self.files[ix].clone();
 722        self.build_ui(cx);
 723    }
 724
 725    fn render_files(&self, _window: &mut Window, cx: &mut Context<SettingsWindow>) -> Div {
 726        h_flex()
 727            .gap_1()
 728            .children(self.files.iter().enumerate().map(|(ix, file)| {
 729                Button::new(ix, file.name())
 730                    .on_click(cx.listener(move |this, _, _window, cx| this.change_file(ix, cx)))
 731            }))
 732    }
 733
 734    fn render_search(&self, _window: &mut Window, cx: &mut App) -> Div {
 735        h_flex()
 736            .pt_1()
 737            .px_1p5()
 738            .gap_1p5()
 739            .rounded_sm()
 740            .bg(cx.theme().colors().editor_background)
 741            .border_1()
 742            .border_color(cx.theme().colors().border)
 743            .child(Icon::new(IconName::MagnifyingGlass).color(Color::Muted))
 744            .child(self.search_bar.clone())
 745    }
 746
 747    fn render_nav(&self, window: &mut Window, cx: &mut Context<SettingsWindow>) -> Div {
 748        v_flex()
 749            .w_64()
 750            .p_2p5()
 751            .pt_10()
 752            .gap_3()
 753            .flex_none()
 754            .border_r_1()
 755            .border_color(cx.theme().colors().border)
 756            .bg(cx.theme().colors().panel_background)
 757            .child(self.render_search(window, cx).pb_1())
 758            .child(
 759                uniform_list(
 760                    "settings-ui-nav-bar",
 761                    self.navbar_entries.len(),
 762                    cx.processor(|this, range: Range<usize>, _, cx| {
 763                        range
 764                            .into_iter()
 765                            .map(|ix| {
 766                                let entry = &this.navbar_entries[ix];
 767
 768                                h_flex()
 769                                    .id(("settings-ui-section", ix))
 770                                    .w_full()
 771                                    .pl_2p5()
 772                                    .py_0p5()
 773                                    .rounded_sm()
 774                                    .border_1()
 775                                    .border_color(cx.theme().colors().border_transparent)
 776                                    .text_color(cx.theme().colors().text_muted)
 777                                    .when(this.is_navbar_entry_selected(ix), |this| {
 778                                        this.text_color(cx.theme().colors().text)
 779                                            .bg(cx.theme().colors().element_selected.opacity(0.2))
 780                                            .border_color(cx.theme().colors().border)
 781                                    })
 782                                    .child(
 783                                        ListItem::new(("settings-ui-navbar-entry", ix))
 784                                            .selectable(true)
 785                                            .inset(true)
 786                                            .indent_step_size(px(1.))
 787                                            .indent_level(if entry.is_root { 1 } else { 3 })
 788                                            .when(entry.is_root, |item| {
 789                                                item.toggle(
 790                                                    this.pages
 791                                                        [this.page_index_from_navbar_index(ix)]
 792                                                    .expanded,
 793                                                )
 794                                                .always_show_disclosure_icon(true)
 795                                                .on_toggle(cx.listener(move |this, _, _, cx| {
 796                                                    this.toggle_navbar_entry(ix);
 797                                                    cx.notify();
 798                                                }))
 799                                            })
 800                                            .child(
 801                                                h_flex()
 802                                                    .text_ui(cx)
 803                                                    .truncate()
 804                                                    .hover(|s| {
 805                                                        s.bg(cx.theme().colors().element_hover)
 806                                                    })
 807                                                    .child(entry.title),
 808                                            ),
 809                                    )
 810                                    .on_click(cx.listener(move |this, _, _, cx| {
 811                                        this.navbar_entry = ix;
 812                                        cx.notify();
 813                                    }))
 814                            })
 815                            .collect()
 816                    }),
 817                )
 818                .track_scroll(self.list_handle.clone())
 819                .size_full()
 820                .flex_grow(),
 821            )
 822    }
 823
 824    fn page_items(&self) -> impl Iterator<Item = &SettingsPageItem> {
 825        let page_idx = self.current_page_index();
 826
 827        self.current_page()
 828            .items
 829            .iter()
 830            .enumerate()
 831            .filter_map(move |(item_index, item)| {
 832                self.search_matches[page_idx][item_index].then_some(item)
 833            })
 834    }
 835
 836    fn render_page(&self, window: &mut Window, cx: &mut Context<SettingsWindow>) -> Div {
 837        v_flex().gap_4().children(
 838            self.page_items()
 839                .map(|item| item.render(self.current_file.clone(), window, cx)),
 840        )
 841    }
 842
 843    fn current_page_index(&self) -> usize {
 844        self.page_index_from_navbar_index(self.navbar_entry)
 845    }
 846
 847    fn current_page(&self) -> &SettingsPage {
 848        &self.pages[self.current_page_index()]
 849    }
 850
 851    fn page_index_from_navbar_index(&self, index: usize) -> usize {
 852        if self.navbar_entries.is_empty() {
 853            return 0;
 854        }
 855
 856        self.navbar_entries[index].page_index
 857    }
 858
 859    fn page_for_navbar_index(&mut self, index: usize) -> &mut SettingsPage {
 860        let index = self.page_index_from_navbar_index(index);
 861        &mut self.pages[index]
 862    }
 863
 864    fn is_navbar_entry_selected(&self, ix: usize) -> bool {
 865        ix == self.navbar_entry
 866    }
 867}
 868
 869impl Render for SettingsWindow {
 870    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
 871        div()
 872            .flex()
 873            .flex_row()
 874            .size_full()
 875            .bg(cx.theme().colors().background)
 876            .text_color(cx.theme().colors().text)
 877            .child(self.render_nav(window, cx))
 878            .child(
 879                v_flex()
 880                    .w_full()
 881                    .pt_4()
 882                    .px_6()
 883                    .gap_4()
 884                    .bg(cx.theme().colors().editor_background)
 885                    .child(self.render_files(window, cx))
 886                    .child(self.render_page(window, cx)),
 887            )
 888    }
 889}
 890
 891// fn read_field<T>(pick: fn(&SettingsContent) -> &Option<T>, file: SettingsFile, cx: &App) -> Option<T> {
 892//     let (_, value) = cx.global::<SettingsStore>().get_value_from_file(file.to_settings(), (), pick);
 893// }
 894
 895fn render_text_field(
 896    field: SettingField<String>,
 897    file: SettingsUiFile,
 898    metadata: Option<&SettingsFieldMetadata>,
 899    cx: &mut App,
 900) -> AnyElement {
 901    let (_, initial_text) =
 902        SettingsStore::global(cx).get_value_from_file(file.to_settings(), field.pick);
 903    let initial_text = Some(initial_text.clone()).filter(|s| !s.is_empty());
 904
 905    SettingsEditor::new()
 906        .when_some(initial_text, |editor, text| editor.with_initial_text(text))
 907        .when_some(
 908            metadata.and_then(|metadata| metadata.placeholder),
 909            |editor, placeholder| editor.with_placeholder(placeholder),
 910        )
 911        .on_confirm(move |new_text, cx: &mut App| {
 912            cx.update_global(move |store: &mut SettingsStore, cx| {
 913                store.update_settings_file(<dyn fs::Fs>::global(cx), move |settings, _cx| {
 914                    *(field.pick_mut)(settings) = new_text;
 915                });
 916            });
 917        })
 918        .into_any_element()
 919}
 920
 921fn render_toggle_button<B: Into<bool> + From<bool> + Copy>(
 922    field: SettingField<B>,
 923    file: SettingsUiFile,
 924    cx: &mut App,
 925) -> AnyElement {
 926    let (_, &value) = SettingsStore::global(cx).get_value_from_file(file.to_settings(), field.pick);
 927
 928    let toggle_state = if value.into() {
 929        ui::ToggleState::Selected
 930    } else {
 931        ui::ToggleState::Unselected
 932    };
 933
 934    Switch::new("toggle_button", toggle_state)
 935        .on_click({
 936            move |state, _window, cx| {
 937                let state = *state == ui::ToggleState::Selected;
 938                let field = field;
 939                cx.update_global(move |store: &mut SettingsStore, cx| {
 940                    store.update_settings_file(<dyn fs::Fs>::global(cx), move |settings, _cx| {
 941                        *(field.pick_mut)(settings) = Some(state.into());
 942                    });
 943                });
 944            }
 945        })
 946        .into_any_element()
 947}
 948
 949fn render_dropdown<T>(
 950    field: SettingField<T>,
 951    file: SettingsUiFile,
 952    window: &mut Window,
 953    cx: &mut App,
 954) -> AnyElement
 955where
 956    T: strum::VariantArray + strum::VariantNames + Copy + PartialEq + Send + 'static,
 957{
 958    let variants = || -> &'static [T] { <T as strum::VariantArray>::VARIANTS };
 959    let labels = || -> &'static [&'static str] { <T as strum::VariantNames>::VARIANTS };
 960
 961    let (_, &current_value) =
 962        SettingsStore::global(cx).get_value_from_file(file.to_settings(), field.pick);
 963
 964    let current_value_label =
 965        labels()[variants().iter().position(|v| *v == current_value).unwrap()];
 966
 967    DropdownMenu::new(
 968        "dropdown",
 969        current_value_label,
 970        ui::ContextMenu::build(window, cx, move |mut menu, _, _| {
 971            for (value, label) in variants()
 972                .into_iter()
 973                .copied()
 974                .zip(labels().into_iter().copied())
 975            {
 976                menu = menu.toggleable_entry(
 977                    label,
 978                    value == current_value,
 979                    ui::IconPosition::Start,
 980                    None,
 981                    move |_, cx| {
 982                        if value == current_value {
 983                            return;
 984                        }
 985                        cx.update_global(move |store: &mut SettingsStore, cx| {
 986                            store.update_settings_file(
 987                                <dyn fs::Fs>::global(cx),
 988                                move |settings, _cx| {
 989                                    *(field.pick_mut)(settings) = Some(value);
 990                                },
 991                            );
 992                        });
 993                    },
 994                );
 995            }
 996            menu
 997        }),
 998    )
 999    .into_any_element()
1000}
1001
1002#[cfg(test)]
1003mod test {
1004
1005    use super::*;
1006
1007    impl SettingsWindow {
1008        fn navbar(&self) -> &[NavBarEntry] {
1009            self.navbar_entries.as_slice()
1010        }
1011
1012        fn navbar_entry(&self) -> usize {
1013            self.navbar_entry
1014        }
1015
1016        fn new_builder(window: &mut Window, cx: &mut Context<Self>) -> Self {
1017            let mut this = Self::new(window, cx);
1018            this.navbar_entries.clear();
1019            this.pages.clear();
1020            this
1021        }
1022
1023        fn build(mut self) -> Self {
1024            self.build_navbar();
1025            self
1026        }
1027
1028        fn add_page(
1029            mut self,
1030            title: &'static str,
1031            build_page: impl Fn(SettingsPage) -> SettingsPage,
1032        ) -> Self {
1033            let page = SettingsPage {
1034                title,
1035                expanded: false,
1036                items: Vec::default(),
1037            };
1038
1039            self.pages.push(build_page(page));
1040            self
1041        }
1042
1043        fn search(&mut self, search_query: &str, window: &mut Window, cx: &mut Context<Self>) {
1044            self.search_task.take();
1045            self.search_bar.update(cx, |editor, cx| {
1046                editor.set_text(search_query, window, cx);
1047            });
1048            self.update_matches(cx);
1049        }
1050
1051        fn assert_search_results(&self, other: &Self) {
1052            // page index could be different because of filtered out pages
1053            assert!(
1054                self.navbar_entries
1055                    .iter()
1056                    .zip(other.navbar_entries.iter())
1057                    .all(|(entry, other)| {
1058                        entry.is_root == other.is_root && entry.title == other.title
1059                    })
1060            );
1061            assert_eq!(
1062                self.current_page().items.iter().collect::<Vec<_>>(),
1063                other.page_items().collect::<Vec<_>>()
1064            );
1065        }
1066    }
1067
1068    impl SettingsPage {
1069        fn item(mut self, item: SettingsPageItem) -> Self {
1070            self.items.push(item);
1071            self
1072        }
1073    }
1074
1075    impl SettingsPageItem {
1076        fn basic_item(title: &'static str, description: &'static str) -> Self {
1077            SettingsPageItem::SettingItem(SettingItem {
1078                title,
1079                description,
1080                field: Box::new(SettingField {
1081                    pick: |settings_content| &settings_content.auto_update,
1082                    pick_mut: |settings_content| &mut settings_content.auto_update,
1083                }),
1084                metadata: None,
1085            })
1086        }
1087    }
1088
1089    fn register_settings(cx: &mut App) {
1090        settings::init(cx);
1091        theme::init(theme::LoadThemes::JustBase, cx);
1092        workspace::init_settings(cx);
1093        project::Project::init_settings(cx);
1094        language::init(cx);
1095        editor::init(cx);
1096        menu::init();
1097    }
1098
1099    fn parse(input: &'static str, window: &mut Window, cx: &mut App) -> SettingsWindow {
1100        let mut pages: Vec<SettingsPage> = Vec::new();
1101        let mut current_page = None;
1102        let mut selected_idx = None;
1103        let mut ix = 0;
1104        let mut in_closed_subentry = false;
1105
1106        for mut line in input
1107            .lines()
1108            .map(|line| line.trim())
1109            .filter(|line| !line.is_empty())
1110        {
1111            let mut is_selected = false;
1112            if line.ends_with("*") {
1113                assert!(
1114                    selected_idx.is_none(),
1115                    "Can only have one selected navbar entry at a time"
1116                );
1117                selected_idx = Some(ix);
1118                line = &line[..line.len() - 1];
1119                is_selected = true;
1120            }
1121
1122            if line.starts_with("v") || line.starts_with(">") {
1123                if let Some(current_page) = current_page.take() {
1124                    pages.push(current_page);
1125                }
1126
1127                let expanded = line.starts_with("v");
1128                in_closed_subentry = !expanded;
1129                ix += 1;
1130
1131                current_page = Some(SettingsPage {
1132                    title: line.split_once(" ").unwrap().1,
1133                    expanded,
1134                    items: Vec::default(),
1135                });
1136            } else if line.starts_with("-") {
1137                if !in_closed_subentry {
1138                    ix += 1;
1139                } else if is_selected && in_closed_subentry {
1140                    panic!("Can't select sub entry if it's parent is closed");
1141                }
1142
1143                let Some(current_page) = current_page.as_mut() else {
1144                    panic!("Sub entries must be within a page");
1145                };
1146
1147                current_page.items.push(SettingsPageItem::SectionHeader(
1148                    line.split_once(" ").unwrap().1,
1149                ));
1150            } else {
1151                panic!(
1152                    "Entries must start with one of 'v', '>', or '-'\n line: {}",
1153                    line
1154                );
1155            }
1156        }
1157
1158        if let Some(current_page) = current_page.take() {
1159            pages.push(current_page);
1160        }
1161
1162        let search_matches = pages
1163            .iter()
1164            .map(|page| vec![true; page.items.len()])
1165            .collect::<Vec<_>>();
1166
1167        let mut settings_window = SettingsWindow {
1168            files: Vec::default(),
1169            current_file: crate::SettingsUiFile::User,
1170            pages,
1171            search_bar: cx.new(|cx| Editor::single_line(window, cx)),
1172            navbar_entry: selected_idx.expect("Must have a selected navbar entry"),
1173            navbar_entries: Vec::default(),
1174            list_handle: UniformListScrollHandle::default(),
1175            search_matches,
1176            search_task: None,
1177        };
1178
1179        settings_window.build_navbar();
1180        settings_window
1181    }
1182
1183    #[track_caller]
1184    fn check_navbar_toggle(
1185        before: &'static str,
1186        toggle_idx: usize,
1187        after: &'static str,
1188        window: &mut Window,
1189        cx: &mut App,
1190    ) {
1191        let mut settings_window = parse(before, window, cx);
1192        settings_window.toggle_navbar_entry(toggle_idx);
1193
1194        let expected_settings_window = parse(after, window, cx);
1195
1196        assert_eq!(settings_window.navbar(), expected_settings_window.navbar());
1197        assert_eq!(
1198            settings_window.navbar_entry(),
1199            expected_settings_window.navbar_entry()
1200        );
1201    }
1202
1203    macro_rules! check_navbar_toggle {
1204        ($name:ident, before: $before:expr, toggle_idx: $toggle_idx:expr, after: $after:expr) => {
1205            #[gpui::test]
1206            fn $name(cx: &mut gpui::TestAppContext) {
1207                let window = cx.add_empty_window();
1208                window.update(|window, cx| {
1209                    register_settings(cx);
1210                    check_navbar_toggle($before, $toggle_idx, $after, window, cx);
1211                });
1212            }
1213        };
1214    }
1215
1216    check_navbar_toggle!(
1217        navbar_basic_open,
1218        before: r"
1219        v General
1220        - General
1221        - Privacy*
1222        v Project
1223        - Project Settings
1224        ",
1225        toggle_idx: 0,
1226        after: r"
1227        > General*
1228        v Project
1229        - Project Settings
1230        "
1231    );
1232
1233    check_navbar_toggle!(
1234        navbar_basic_close,
1235        before: r"
1236        > General*
1237        - General
1238        - Privacy
1239        v Project
1240        - Project Settings
1241        ",
1242        toggle_idx: 0,
1243        after: r"
1244        v General*
1245        - General
1246        - Privacy
1247        v Project
1248        - Project Settings
1249        "
1250    );
1251
1252    check_navbar_toggle!(
1253        navbar_basic_second_root_entry_close,
1254        before: r"
1255        > General
1256        - General
1257        - Privacy
1258        v Project
1259        - Project Settings*
1260        ",
1261        toggle_idx: 1,
1262        after: r"
1263        > General
1264        > Project*
1265        "
1266    );
1267
1268    check_navbar_toggle!(
1269        navbar_toggle_subroot,
1270        before: r"
1271        v General Page
1272        - General
1273        - Privacy
1274        v Project
1275        - Worktree Settings Content*
1276        v AI
1277        - General
1278        > Appearance & Behavior
1279        ",
1280        toggle_idx: 3,
1281        after: r"
1282        v General Page
1283        - General
1284        - Privacy
1285        > Project*
1286        v AI
1287        - General
1288        > Appearance & Behavior
1289        "
1290    );
1291
1292    check_navbar_toggle!(
1293        navbar_toggle_close_propagates_selected_index,
1294        before: r"
1295        v General Page
1296        - General
1297        - Privacy
1298        v Project
1299        - Worktree Settings Content
1300        v AI
1301        - General*
1302        > Appearance & Behavior
1303        ",
1304        toggle_idx: 0,
1305        after: r"
1306        > General Page
1307        v Project
1308        - Worktree Settings Content
1309        v AI
1310        - General*
1311        > Appearance & Behavior
1312        "
1313    );
1314
1315    check_navbar_toggle!(
1316        navbar_toggle_expand_propagates_selected_index,
1317        before: r"
1318        > General Page
1319        - General
1320        - Privacy
1321        v Project
1322        - Worktree Settings Content
1323        v AI
1324        - General*
1325        > Appearance & Behavior
1326        ",
1327        toggle_idx: 0,
1328        after: r"
1329        v General Page
1330        - General
1331        - Privacy
1332        v Project
1333        - Worktree Settings Content
1334        v AI
1335        - General*
1336        > Appearance & Behavior
1337        "
1338    );
1339
1340    check_navbar_toggle!(
1341        navbar_toggle_sub_entry_does_nothing,
1342        before: r"
1343        > General Page
1344        - General
1345        - Privacy
1346        v Project
1347        - Worktree Settings Content
1348        v AI
1349        - General*
1350        > Appearance & Behavior
1351        ",
1352        toggle_idx: 4,
1353        after: r"
1354        > General Page
1355        - General
1356        - Privacy
1357        v Project
1358        - Worktree Settings Content
1359        v AI
1360        - General*
1361        > Appearance & Behavior
1362        "
1363    );
1364
1365    #[gpui::test]
1366    fn test_basic_search(cx: &mut gpui::TestAppContext) {
1367        let cx = cx.add_empty_window();
1368        let (actual, expected) = cx.update(|window, cx| {
1369            register_settings(cx);
1370
1371            let expected = cx.new(|cx| {
1372                SettingsWindow::new_builder(window, cx)
1373                    .add_page("General", |page| {
1374                        page.item(SettingsPageItem::SectionHeader("General settings"))
1375                            .item(SettingsPageItem::basic_item("test title", "General test"))
1376                    })
1377                    .build()
1378            });
1379
1380            let actual = cx.new(|cx| {
1381                SettingsWindow::new_builder(window, cx)
1382                    .add_page("General", |page| {
1383                        page.item(SettingsPageItem::SectionHeader("General settings"))
1384                            .item(SettingsPageItem::basic_item("test title", "General test"))
1385                    })
1386                    .add_page("Theme", |page| {
1387                        page.item(SettingsPageItem::SectionHeader("Theme settings"))
1388                    })
1389                    .build()
1390            });
1391
1392            actual.update(cx, |settings, cx| settings.search("gen", window, cx));
1393
1394            (actual, expected)
1395        });
1396
1397        cx.cx.run_until_parked();
1398
1399        cx.update(|_window, cx| {
1400            let expected = expected.read(cx);
1401            let actual = actual.read(cx);
1402            expected.assert_search_results(&actual);
1403        })
1404    }
1405
1406    #[gpui::test]
1407    fn test_search_render_page_with_filtered_out_navbar_entries(cx: &mut gpui::TestAppContext) {
1408        let cx = cx.add_empty_window();
1409        let (actual, expected) = cx.update(|window, cx| {
1410            register_settings(cx);
1411
1412            let actual = cx.new(|cx| {
1413                SettingsWindow::new_builder(window, cx)
1414                    .add_page("General", |page| {
1415                        page.item(SettingsPageItem::SectionHeader("General settings"))
1416                            .item(SettingsPageItem::basic_item(
1417                                "Confirm Quit",
1418                                "Whether to confirm before quitting Zed",
1419                            ))
1420                            .item(SettingsPageItem::basic_item(
1421                                "Auto Update",
1422                                "Automatically update Zed",
1423                            ))
1424                    })
1425                    .add_page("AI", |page| {
1426                        page.item(SettingsPageItem::basic_item(
1427                            "Disable AI",
1428                            "Whether to disable all AI features in Zed",
1429                        ))
1430                    })
1431                    .add_page("Appearance & Behavior", |page| {
1432                        page.item(SettingsPageItem::SectionHeader("Cursor")).item(
1433                            SettingsPageItem::basic_item(
1434                                "Cursor Shape",
1435                                "Cursor shape for the editor",
1436                            ),
1437                        )
1438                    })
1439                    .build()
1440            });
1441
1442            let expected = cx.new(|cx| {
1443                SettingsWindow::new_builder(window, cx)
1444                    .add_page("Appearance & Behavior", |page| {
1445                        page.item(SettingsPageItem::SectionHeader("Cursor")).item(
1446                            SettingsPageItem::basic_item(
1447                                "Cursor Shape",
1448                                "Cursor shape for the editor",
1449                            ),
1450                        )
1451                    })
1452                    .build()
1453            });
1454
1455            actual.update(cx, |settings, cx| settings.search("cursor", window, cx));
1456
1457            (actual, expected)
1458        });
1459
1460        cx.cx.run_until_parked();
1461
1462        cx.update(|_window, cx| {
1463            let expected = expected.read(cx);
1464            let actual = actual.read(cx);
1465            expected.assert_search_results(&actual);
1466        })
1467    }
1468}