settings_ui.rs

   1//! # settings_ui
   2mod components;
   3mod page_data;
   4
   5use anyhow::Result;
   6use editor::{Editor, EditorEvent};
   7use feature_flags::{FeatureFlag, FeatureFlagAppExt as _};
   8use fuzzy::StringMatchCandidate;
   9use gpui::{
  10    Action, App, Div, Entity, FocusHandle, Focusable, FontWeight, Global, ReadGlobal as _,
  11    ScrollHandle, Task, TitlebarOptions, UniformListScrollHandle, Window, WindowHandle,
  12    WindowOptions, actions, div, point, prelude::*, px, size, uniform_list,
  13};
  14use heck::ToTitleCase as _;
  15use project::WorktreeId;
  16use schemars::JsonSchema;
  17use serde::Deserialize;
  18use settings::{
  19    BottomDockLayout, CloseWindowWhenNoItems, CodeFade, CursorShape, OnLastWindowClosed,
  20    RestoreOnStartupBehavior, SaturatingBool, SettingsContent, SettingsStore,
  21};
  22use std::{
  23    any::{Any, TypeId, type_name},
  24    cell::RefCell,
  25    collections::HashMap,
  26    num::{NonZero, NonZeroU32},
  27    ops::Range,
  28    rc::Rc,
  29    sync::{Arc, LazyLock, RwLock, atomic::AtomicBool},
  30};
  31use ui::{
  32    ContextMenu, Divider, DividerColor, DropdownMenu, DropdownStyle, IconButtonShape, KeyBinding,
  33    KeybindingHint, PopoverMenu, Switch, SwitchColor, Tooltip, TreeViewItem, WithScrollbar,
  34    prelude::*,
  35};
  36use ui_input::{NumberField, NumberFieldType};
  37use util::{ResultExt as _, paths::PathStyle, rel_path::RelPath};
  38use workspace::{OpenOptions, OpenVisible, Workspace};
  39use zed_actions::OpenSettingsEditor;
  40
  41use crate::components::SettingsEditor;
  42
  43const NAVBAR_CONTAINER_TAB_INDEX: isize = 0;
  44const NAVBAR_GROUP_TAB_INDEX: isize = 1;
  45const CONTENT_CONTAINER_TAB_INDEX: isize = 2;
  46const CONTENT_GROUP_TAB_INDEX: isize = 3;
  47
  48actions!(
  49    settings_editor,
  50    [
  51        /// Minimizes the settings UI window.
  52        Minimize,
  53        /// Toggles focus between the navbar and the main content.
  54        ToggleFocusNav,
  55        /// Focuses the next file in the file list.
  56        FocusNextFile,
  57        /// Focuses the previous file in the file list.
  58        FocusPreviousFile,
  59        /// Opens an editor for the current file
  60        OpenCurrentFile,
  61    ]
  62);
  63
  64#[derive(Action, PartialEq, Eq, Clone, Copy, Debug, JsonSchema, Deserialize)]
  65#[action(namespace = settings_editor)]
  66struct FocusFile(pub u32);
  67
  68#[derive(Clone, Copy)]
  69struct SettingField<T: 'static> {
  70    pick: fn(&SettingsContent) -> &Option<T>,
  71    pick_mut: fn(&mut SettingsContent) -> &mut Option<T>,
  72}
  73
  74/// Helper for unimplemented settings, used in combination with `SettingField::unimplemented`
  75/// to keep the setting around in the UI with valid pick and pick_mut implementations, but don't actually try to render it.
  76/// TODO(settings_ui): In non-dev builds (`#[cfg(not(debug_assertions))]`) make this render as edit-in-json
  77struct UnimplementedSettingField;
  78
  79impl<T: 'static> SettingField<T> {
  80    /// Helper for settings with types that are not yet implemented.
  81    #[allow(unused)]
  82    fn unimplemented(self) -> SettingField<UnimplementedSettingField> {
  83        SettingField {
  84            pick: |_| &None,
  85            pick_mut: |_| unreachable!(),
  86        }
  87    }
  88}
  89
  90trait AnySettingField {
  91    fn as_any(&self) -> &dyn Any;
  92    fn type_name(&self) -> &'static str;
  93    fn type_id(&self) -> TypeId;
  94    // Returns the file this value was set in and true, or File::Default and false to indicate it was not found in any file (missing default)
  95    fn file_set_in(&self, file: SettingsUiFile, cx: &App) -> (settings::SettingsFile, bool);
  96}
  97
  98impl<T> AnySettingField for SettingField<T> {
  99    fn as_any(&self) -> &dyn Any {
 100        self
 101    }
 102
 103    fn type_name(&self) -> &'static str {
 104        type_name::<T>()
 105    }
 106
 107    fn type_id(&self) -> TypeId {
 108        TypeId::of::<T>()
 109    }
 110
 111    fn file_set_in(&self, file: SettingsUiFile, cx: &App) -> (settings::SettingsFile, bool) {
 112        if AnySettingField::type_id(self) == TypeId::of::<UnimplementedSettingField>() {
 113            return (file.to_settings(), true);
 114        }
 115
 116        let (file, value) = cx
 117            .global::<SettingsStore>()
 118            .get_value_from_file(file.to_settings(), self.pick);
 119        return (file, value.is_some());
 120    }
 121}
 122
 123#[derive(Default, Clone)]
 124struct SettingFieldRenderer {
 125    renderers: Rc<
 126        RefCell<
 127            HashMap<
 128                TypeId,
 129                Box<
 130                    dyn Fn(
 131                        &dyn AnySettingField,
 132                        SettingsUiFile,
 133                        Option<&SettingsFieldMetadata>,
 134                        &mut Window,
 135                        &mut App,
 136                    ) -> AnyElement,
 137                >,
 138            >,
 139        >,
 140    >,
 141}
 142
 143impl Global for SettingFieldRenderer {}
 144
 145impl SettingFieldRenderer {
 146    fn add_renderer<T: 'static>(
 147        &mut self,
 148        renderer: impl Fn(
 149            &SettingField<T>,
 150            SettingsUiFile,
 151            Option<&SettingsFieldMetadata>,
 152            &mut Window,
 153            &mut App,
 154        ) -> AnyElement
 155        + 'static,
 156    ) -> &mut Self {
 157        let key = TypeId::of::<T>();
 158        let renderer = Box::new(
 159            move |any_setting_field: &dyn AnySettingField,
 160                  settings_file: SettingsUiFile,
 161                  metadata: Option<&SettingsFieldMetadata>,
 162                  window: &mut Window,
 163                  cx: &mut App| {
 164                let field = any_setting_field
 165                    .as_any()
 166                    .downcast_ref::<SettingField<T>>()
 167                    .unwrap();
 168                renderer(field, settings_file, metadata, window, cx)
 169            },
 170        );
 171        self.renderers.borrow_mut().insert(key, renderer);
 172        self
 173    }
 174
 175    fn render(
 176        &self,
 177        any_setting_field: &dyn AnySettingField,
 178        settings_file: SettingsUiFile,
 179        metadata: Option<&SettingsFieldMetadata>,
 180        window: &mut Window,
 181        cx: &mut App,
 182    ) -> AnyElement {
 183        let key = any_setting_field.type_id();
 184        if let Some(renderer) = self.renderers.borrow().get(&key) {
 185            renderer(any_setting_field, settings_file, metadata, window, cx)
 186        } else {
 187            panic!(
 188                "No renderer found for type: {}",
 189                any_setting_field.type_name()
 190            )
 191        }
 192    }
 193}
 194
 195struct SettingsFieldMetadata {
 196    placeholder: Option<&'static str>,
 197}
 198
 199pub struct SettingsUiFeatureFlag;
 200
 201impl FeatureFlag for SettingsUiFeatureFlag {
 202    const NAME: &'static str = "settings-ui";
 203}
 204
 205pub fn init(cx: &mut App) {
 206    init_renderers(cx);
 207
 208    cx.observe_new(|workspace: &mut workspace::Workspace, _, _| {
 209        workspace.register_action_renderer(|div, _, _, cx| {
 210            let settings_ui_actions = [
 211                TypeId::of::<OpenSettingsEditor>(),
 212                TypeId::of::<ToggleFocusNav>(),
 213                TypeId::of::<FocusFile>(),
 214                TypeId::of::<FocusNextFile>(),
 215                TypeId::of::<FocusPreviousFile>(),
 216            ];
 217            let has_flag = cx.has_flag::<SettingsUiFeatureFlag>();
 218            command_palette_hooks::CommandPaletteFilter::update_global(cx, |filter, _| {
 219                if has_flag {
 220                    filter.show_action_types(&settings_ui_actions);
 221                } else {
 222                    filter.hide_action_types(&settings_ui_actions);
 223                }
 224            });
 225            if has_flag {
 226                div.on_action(
 227                    cx.listener(|workspace, _: &OpenSettingsEditor, window, cx| {
 228                        let window_handle = window
 229                            .window_handle()
 230                            .downcast::<Workspace>()
 231                            .expect("Workspaces are root Windows");
 232                        open_settings_editor(workspace, window_handle, cx);
 233                    }),
 234                )
 235            } else {
 236                div
 237            }
 238        });
 239    })
 240    .detach();
 241}
 242
 243fn init_renderers(cx: &mut App) {
 244    cx.default_global::<SettingFieldRenderer>()
 245        .add_renderer::<UnimplementedSettingField>(|_, _, _, _, _| {
 246            Button::new("open-in-settings-file", "Edit in settings.json")
 247                .size(ButtonSize::Default)
 248                .style(ButtonStyle::Outlined)
 249                .on_click(|_, window, cx| {
 250                    window.dispatch_action(Box::new(OpenCurrentFile), cx);
 251                })
 252                .into_any_element()
 253        })
 254        .add_renderer::<bool>(|settings_field, file, _, _, cx| {
 255            render_toggle_button(*settings_field, file, cx).into_any_element()
 256        })
 257        .add_renderer::<String>(|settings_field, file, metadata, _, cx| {
 258            render_text_field(settings_field.clone(), file, metadata, cx)
 259        })
 260        .add_renderer::<SaturatingBool>(|settings_field, file, _, _, cx| {
 261            render_toggle_button(*settings_field, file, cx)
 262        })
 263        .add_renderer::<CursorShape>(|settings_field, file, _, window, cx| {
 264            render_dropdown(*settings_field, file, window, cx)
 265        })
 266        .add_renderer::<RestoreOnStartupBehavior>(|settings_field, file, _, window, cx| {
 267            render_dropdown(*settings_field, file, window, cx)
 268        })
 269        .add_renderer::<BottomDockLayout>(|settings_field, file, _, window, cx| {
 270            render_dropdown(*settings_field, file, window, cx)
 271        })
 272        .add_renderer::<OnLastWindowClosed>(|settings_field, file, _, window, cx| {
 273            render_dropdown(*settings_field, file, window, cx)
 274        })
 275        .add_renderer::<CloseWindowWhenNoItems>(|settings_field, file, _, window, cx| {
 276            render_dropdown(*settings_field, file, window, cx)
 277        })
 278        .add_renderer::<settings::FontFamilyName>(|settings_field, file, _, window, cx| {
 279            // todo(settings_ui): We need to pass in a validator for this to ensure that users that type in invalid font names
 280            render_font_picker(settings_field.clone(), file, window, cx)
 281        })
 282        // todo(settings_ui): This needs custom ui
 283        // .add_renderer::<settings::BufferLineHeight>(|settings_field, file, _, window, cx| {
 284        //     // todo(settings_ui): Do we want to expose the custom variant of buffer line height?
 285        //     // right now there's a manual impl of strum::VariantArray
 286        //     render_dropdown(*settings_field, file, window, cx)
 287        // })
 288        .add_renderer::<settings::BaseKeymapContent>(|settings_field, file, _, window, cx| {
 289            render_dropdown(*settings_field, file, window, cx)
 290        })
 291        .add_renderer::<settings::MultiCursorModifier>(|settings_field, file, _, window, cx| {
 292            render_dropdown(*settings_field, file, window, cx)
 293        })
 294        .add_renderer::<settings::HideMouseMode>(|settings_field, file, _, window, cx| {
 295            render_dropdown(*settings_field, file, window, cx)
 296        })
 297        .add_renderer::<settings::CurrentLineHighlight>(|settings_field, file, _, window, cx| {
 298            render_dropdown(*settings_field, file, window, cx)
 299        })
 300        .add_renderer::<settings::ShowWhitespaceSetting>(|settings_field, file, _, window, cx| {
 301            render_dropdown(*settings_field, file, window, cx)
 302        })
 303        .add_renderer::<settings::SoftWrap>(|settings_field, file, _, window, cx| {
 304            render_dropdown(*settings_field, file, window, cx)
 305        })
 306        .add_renderer::<settings::ScrollBeyondLastLine>(|settings_field, file, _, window, cx| {
 307            render_dropdown(*settings_field, file, window, cx)
 308        })
 309        .add_renderer::<settings::SnippetSortOrder>(|settings_field, file, _, window, cx| {
 310            render_dropdown(*settings_field, file, window, cx)
 311        })
 312        .add_renderer::<settings::ClosePosition>(|settings_field, file, _, window, cx| {
 313            render_dropdown(*settings_field, file, window, cx)
 314        })
 315        .add_renderer::<settings::DockSide>(|settings_field, file, _, window, cx| {
 316            render_dropdown(*settings_field, file, window, cx)
 317        })
 318        .add_renderer::<settings::TerminalDockPosition>(|settings_field, file, _, window, cx| {
 319            render_dropdown(*settings_field, file, window, cx)
 320        })
 321        .add_renderer::<settings::DockPosition>(|settings_field, file, _, window, cx| {
 322            render_dropdown(*settings_field, file, window, cx)
 323        })
 324        .add_renderer::<settings::GitGutterSetting>(|settings_field, file, _, window, cx| {
 325            render_dropdown(*settings_field, file, window, cx)
 326        })
 327        .add_renderer::<settings::GitHunkStyleSetting>(|settings_field, file, _, window, cx| {
 328            render_dropdown(*settings_field, file, window, cx)
 329        })
 330        .add_renderer::<settings::DiagnosticSeverityContent>(
 331            |settings_field, file, _, window, cx| {
 332                render_dropdown(*settings_field, file, window, cx)
 333            },
 334        )
 335        .add_renderer::<settings::SeedQuerySetting>(|settings_field, file, _, window, cx| {
 336            render_dropdown(*settings_field, file, window, cx)
 337        })
 338        .add_renderer::<settings::DoubleClickInMultibuffer>(
 339            |settings_field, file, _, window, cx| {
 340                render_dropdown(*settings_field, file, window, cx)
 341            },
 342        )
 343        .add_renderer::<settings::GoToDefinitionFallback>(|settings_field, file, _, window, cx| {
 344            render_dropdown(*settings_field, file, window, cx)
 345        })
 346        .add_renderer::<settings::ActivateOnClose>(|settings_field, file, _, window, cx| {
 347            render_dropdown(*settings_field, file, window, cx)
 348        })
 349        .add_renderer::<settings::ShowDiagnostics>(|settings_field, file, _, window, cx| {
 350            render_dropdown(*settings_field, file, window, cx)
 351        })
 352        .add_renderer::<settings::ShowCloseButton>(|settings_field, file, _, window, cx| {
 353            render_dropdown(*settings_field, file, window, cx)
 354        })
 355        .add_renderer::<settings::ProjectPanelEntrySpacing>(
 356            |settings_field, file, _, window, cx| {
 357                render_dropdown(*settings_field, file, window, cx)
 358            },
 359        )
 360        .add_renderer::<settings::RewrapBehavior>(|settings_field, file, _, window, cx| {
 361            render_dropdown(*settings_field, file, window, cx)
 362        })
 363        .add_renderer::<settings::FormatOnSave>(|settings_field, file, _, window, cx| {
 364            render_dropdown(*settings_field, file, window, cx)
 365        })
 366        .add_renderer::<settings::IndentGuideColoring>(|settings_field, file, _, window, cx| {
 367            render_dropdown(*settings_field, file, window, cx)
 368        })
 369        .add_renderer::<settings::IndentGuideBackgroundColoring>(
 370            |settings_field, file, _, window, cx| {
 371                render_dropdown(*settings_field, file, window, cx)
 372            },
 373        )
 374        .add_renderer::<settings::FileFinderWidthContent>(|settings_field, file, _, window, cx| {
 375            render_dropdown(*settings_field, file, window, cx)
 376        })
 377        .add_renderer::<settings::ShowDiagnostics>(|settings_field, file, _, window, cx| {
 378            render_dropdown(*settings_field, file, window, cx)
 379        })
 380        .add_renderer::<settings::WordsCompletionMode>(|settings_field, file, _, window, cx| {
 381            render_dropdown(*settings_field, file, window, cx)
 382        })
 383        .add_renderer::<settings::LspInsertMode>(|settings_field, file, _, window, cx| {
 384            render_dropdown(*settings_field, file, window, cx)
 385        })
 386        .add_renderer::<f32>(|settings_field, file, _, window, cx| {
 387            render_number_field(*settings_field, file, window, cx)
 388        })
 389        .add_renderer::<u32>(|settings_field, file, _, window, cx| {
 390            render_number_field(*settings_field, file, window, cx)
 391        })
 392        .add_renderer::<u64>(|settings_field, file, _, window, cx| {
 393            render_number_field(*settings_field, file, window, cx)
 394        })
 395        .add_renderer::<usize>(|settings_field, file, _, window, cx| {
 396            render_number_field(*settings_field, file, window, cx)
 397        })
 398        .add_renderer::<NonZero<usize>>(|settings_field, file, _, window, cx| {
 399            render_number_field(*settings_field, file, window, cx)
 400        })
 401        .add_renderer::<NonZeroU32>(|settings_field, file, _, window, cx| {
 402            render_number_field(*settings_field, file, window, cx)
 403        })
 404        .add_renderer::<CodeFade>(|settings_field, file, _, window, cx| {
 405            render_number_field(*settings_field, file, window, cx)
 406        })
 407        .add_renderer::<FontWeight>(|settings_field, file, _, window, cx| {
 408            render_number_field(*settings_field, file, window, cx)
 409        })
 410        .add_renderer::<settings::MinimumContrast>(|settings_field, file, _, window, cx| {
 411            render_number_field(*settings_field, file, window, cx)
 412        })
 413        .add_renderer::<settings::ShowScrollbar>(|settings_field, file, _, window, cx| {
 414            render_dropdown(*settings_field, file, window, cx)
 415        })
 416        .add_renderer::<settings::ScrollbarDiagnostics>(|settings_field, file, _, window, cx| {
 417            render_dropdown(*settings_field, file, window, cx)
 418        })
 419        .add_renderer::<settings::ShowMinimap>(|settings_field, file, _, window, cx| {
 420            render_dropdown(*settings_field, file, window, cx)
 421        })
 422        .add_renderer::<settings::DisplayIn>(|settings_field, file, _, window, cx| {
 423            render_dropdown(*settings_field, file, window, cx)
 424        })
 425        .add_renderer::<settings::MinimapThumb>(|settings_field, file, _, window, cx| {
 426            render_dropdown(*settings_field, file, window, cx)
 427        })
 428        .add_renderer::<settings::MinimapThumbBorder>(|settings_field, file, _, window, cx| {
 429            render_dropdown(*settings_field, file, window, cx)
 430        })
 431        .add_renderer::<settings::SteppingGranularity>(|settings_field, file, _, window, cx| {
 432            render_dropdown(*settings_field, file, window, cx)
 433        });
 434
 435    // todo(settings_ui): Figure out how we want to handle discriminant unions
 436    // .add_renderer::<ThemeSelection>(|settings_field, file, _, window, cx| {
 437    //     render_dropdown(*settings_field, file, window, cx)
 438    // });
 439}
 440
 441pub fn open_settings_editor(
 442    _workspace: &mut Workspace,
 443    workspace_handle: WindowHandle<Workspace>,
 444    cx: &mut App,
 445) {
 446    let existing_window = cx
 447        .windows()
 448        .into_iter()
 449        .find_map(|window| window.downcast::<SettingsWindow>());
 450
 451    if let Some(existing_window) = existing_window {
 452        existing_window
 453            .update(cx, |settings_window, window, _| {
 454                settings_window.original_window = Some(workspace_handle);
 455                window.activate_window();
 456            })
 457            .ok();
 458        return;
 459    }
 460
 461    // We have to defer this to get the workspace off the stack.
 462
 463    cx.defer(move |cx| {
 464        cx.open_window(
 465            WindowOptions {
 466                titlebar: Some(TitlebarOptions {
 467                    title: Some("Settings Window".into()),
 468                    appears_transparent: true,
 469                    traffic_light_position: Some(point(px(12.0), px(12.0))),
 470                }),
 471                focus: true,
 472                show: true,
 473                kind: gpui::WindowKind::Normal,
 474                window_background: cx.theme().window_background_appearance(),
 475                window_min_size: Some(size(px(800.), px(600.))), // 4:3 Aspect Ratio
 476                ..Default::default()
 477            },
 478            |window, cx| cx.new(|cx| SettingsWindow::new(Some(workspace_handle), window, cx)),
 479        )
 480        .log_err();
 481    });
 482}
 483
 484/// The current sub page path that is selected.
 485/// If this is empty the selected page is rendered,
 486/// otherwise the last sub page gets rendered.
 487///
 488/// Global so that `pick` and `pick_mut` callbacks can access it
 489/// and use it to dynamically render sub pages (e.g. for language settings)
 490static SUB_PAGE_STACK: LazyLock<RwLock<Vec<SubPage>>> = LazyLock::new(|| RwLock::new(Vec::new()));
 491
 492fn sub_page_stack() -> std::sync::RwLockReadGuard<'static, Vec<SubPage>> {
 493    SUB_PAGE_STACK
 494        .read()
 495        .expect("SUB_PAGE_STACK is never poisoned")
 496}
 497
 498fn sub_page_stack_mut() -> std::sync::RwLockWriteGuard<'static, Vec<SubPage>> {
 499    SUB_PAGE_STACK
 500        .write()
 501        .expect("SUB_PAGE_STACK is never poisoned")
 502}
 503
 504pub struct SettingsWindow {
 505    original_window: Option<WindowHandle<Workspace>>,
 506    files: Vec<(SettingsUiFile, FocusHandle)>,
 507    worktree_root_dirs: HashMap<WorktreeId, String>,
 508    current_file: SettingsUiFile,
 509    pages: Vec<SettingsPage>,
 510    search_bar: Entity<Editor>,
 511    search_task: Option<Task<()>>,
 512    /// Index into navbar_entries
 513    navbar_entry: usize,
 514    navbar_entries: Vec<NavBarEntry>,
 515    list_handle: UniformListScrollHandle,
 516    search_matches: Vec<Vec<bool>>,
 517    scroll_handle: ScrollHandle,
 518    focus_handle: FocusHandle,
 519    navbar_focus_handle: FocusHandle,
 520    content_focus_handle: FocusHandle,
 521    files_focus_handle: FocusHandle,
 522}
 523
 524struct SubPage {
 525    link: SubPageLink,
 526    section_header: &'static str,
 527}
 528
 529#[derive(PartialEq, Debug)]
 530struct NavBarEntry {
 531    title: &'static str,
 532    is_root: bool,
 533    expanded: bool,
 534    page_index: usize,
 535    item_index: Option<usize>,
 536}
 537
 538struct SettingsPage {
 539    title: &'static str,
 540    items: Vec<SettingsPageItem>,
 541}
 542
 543#[derive(PartialEq)]
 544enum SettingsPageItem {
 545    SectionHeader(&'static str),
 546    SettingItem(SettingItem),
 547    SubPageLink(SubPageLink),
 548}
 549
 550impl std::fmt::Debug for SettingsPageItem {
 551    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
 552        match self {
 553            SettingsPageItem::SectionHeader(header) => write!(f, "SectionHeader({})", header),
 554            SettingsPageItem::SettingItem(setting_item) => {
 555                write!(f, "SettingItem({})", setting_item.title)
 556            }
 557            SettingsPageItem::SubPageLink(sub_page_link) => {
 558                write!(f, "SubPageLink({})", sub_page_link.title)
 559            }
 560        }
 561    }
 562}
 563
 564impl SettingsPageItem {
 565    fn render(
 566        &self,
 567        settings_window: &SettingsWindow,
 568        section_header: &'static str,
 569        is_last: bool,
 570        window: &mut Window,
 571        cx: &mut Context<SettingsWindow>,
 572    ) -> AnyElement {
 573        let file = settings_window.current_file.clone();
 574        match self {
 575            SettingsPageItem::SectionHeader(header) => v_flex()
 576                .w_full()
 577                .gap_1p5()
 578                .child(
 579                    Label::new(SharedString::new_static(header))
 580                        .size(LabelSize::Small)
 581                        .color(Color::Muted)
 582                        .buffer_font(cx),
 583                )
 584                .child(Divider::horizontal().color(DividerColor::BorderFaded))
 585                .into_any_element(),
 586            SettingsPageItem::SettingItem(setting_item) => {
 587                let renderer = cx.default_global::<SettingFieldRenderer>().clone();
 588                let (found_in_file, found) = setting_item.field.file_set_in(file.clone(), cx);
 589                let file_set_in = SettingsUiFile::from_settings(found_in_file);
 590
 591                h_flex()
 592                    .id(setting_item.title)
 593                    .w_full()
 594                    .min_w_0()
 595                    .gap_2()
 596                    .justify_between()
 597                    .map(|this| {
 598                        if is_last {
 599                            this.pb_6()
 600                        } else {
 601                            this.pb_4()
 602                                .border_b_1()
 603                                .border_color(cx.theme().colors().border_variant)
 604                        }
 605                    })
 606                    .child(
 607                        v_flex()
 608                            .w_full()
 609                            .max_w_1_2()
 610                            .child(
 611                                h_flex()
 612                                    .w_full()
 613                                    .gap_1()
 614                                    .child(Label::new(SharedString::new_static(setting_item.title)))
 615                                    .when_some(
 616                                        file_set_in.filter(|file_set_in| file_set_in != &file),
 617                                        |this, file_set_in| {
 618                                            this.child(
 619                                                Label::new(format!(
 620                                                    "— set in {}",
 621                                                    settings_window
 622                                                        .display_name(&file_set_in)
 623                                                        .expect("File name should exist")
 624                                                ))
 625                                                .color(Color::Muted)
 626                                                .size(LabelSize::Small),
 627                                            )
 628                                        },
 629                                    ),
 630                            )
 631                            .child(
 632                                Label::new(SharedString::new_static(setting_item.description))
 633                                    .size(LabelSize::Small)
 634                                    .color(Color::Muted),
 635                            ),
 636                    )
 637                    .child(if cfg!(debug_assertions) && !found {
 638                        Button::new("no-default-field", "NO DEFAULT")
 639                            .size(ButtonSize::Medium)
 640                            .icon(IconName::XCircle)
 641                            .icon_position(IconPosition::Start)
 642                            .icon_color(Color::Error)
 643                            .icon_size(IconSize::Small)
 644                            .style(ButtonStyle::Outlined)
 645                            .tooltip(Tooltip::text(
 646                                "This warning is only displayed in dev builds.",
 647                            ))
 648                            .into_any_element()
 649                    } else {
 650                        renderer.render(
 651                            setting_item.field.as_ref(),
 652                            file,
 653                            setting_item.metadata.as_deref(),
 654                            window,
 655                            cx,
 656                        )
 657                    })
 658                    .into_any_element()
 659            }
 660            SettingsPageItem::SubPageLink(sub_page_link) => h_flex()
 661                .id(sub_page_link.title)
 662                .w_full()
 663                .gap_2()
 664                .flex_wrap()
 665                .justify_between()
 666                .when(!is_last, |this| {
 667                    this.pb_4()
 668                        .border_b_1()
 669                        .border_color(cx.theme().colors().border_variant)
 670                })
 671                .child(
 672                    v_flex()
 673                        .max_w_1_2()
 674                        .flex_shrink()
 675                        .child(Label::new(SharedString::new_static(sub_page_link.title))),
 676                )
 677                .child(
 678                    Button::new(("sub-page".into(), sub_page_link.title), "Configure")
 679                        .icon(IconName::ChevronRight)
 680                        .icon_position(IconPosition::End)
 681                        .icon_color(Color::Muted)
 682                        .icon_size(IconSize::Small)
 683                        .style(ButtonStyle::Outlined),
 684                )
 685                .on_click({
 686                    let sub_page_link = sub_page_link.clone();
 687                    cx.listener(move |this, _, _, cx| {
 688                        this.push_sub_page(sub_page_link.clone(), section_header, cx)
 689                    })
 690                })
 691                .into_any_element(),
 692        }
 693    }
 694}
 695
 696struct SettingItem {
 697    title: &'static str,
 698    description: &'static str,
 699    field: Box<dyn AnySettingField>,
 700    metadata: Option<Box<SettingsFieldMetadata>>,
 701    files: FileMask,
 702}
 703
 704#[derive(PartialEq, Eq, Clone, Copy)]
 705struct FileMask(u8);
 706
 707impl std::fmt::Debug for FileMask {
 708    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
 709        write!(f, "FileMask(")?;
 710        let mut items = vec![];
 711
 712        if self.contains(USER) {
 713            items.push("USER");
 714        }
 715        if self.contains(LOCAL) {
 716            items.push("LOCAL");
 717        }
 718        if self.contains(SERVER) {
 719            items.push("SERVER");
 720        }
 721
 722        write!(f, "{})", items.join(" | "))
 723    }
 724}
 725
 726const USER: FileMask = FileMask(1 << 0);
 727const LOCAL: FileMask = FileMask(1 << 2);
 728const SERVER: FileMask = FileMask(1 << 3);
 729
 730impl std::ops::BitAnd for FileMask {
 731    type Output = Self;
 732
 733    fn bitand(self, other: Self) -> Self {
 734        Self(self.0 & other.0)
 735    }
 736}
 737
 738impl std::ops::BitOr for FileMask {
 739    type Output = Self;
 740
 741    fn bitor(self, other: Self) -> Self {
 742        Self(self.0 | other.0)
 743    }
 744}
 745
 746impl FileMask {
 747    fn contains(&self, other: FileMask) -> bool {
 748        self.0 & other.0 != 0
 749    }
 750}
 751
 752impl PartialEq for SettingItem {
 753    fn eq(&self, other: &Self) -> bool {
 754        self.title == other.title
 755            && self.description == other.description
 756            && (match (&self.metadata, &other.metadata) {
 757                (None, None) => true,
 758                (Some(m1), Some(m2)) => m1.placeholder == m2.placeholder,
 759                _ => false,
 760            })
 761    }
 762}
 763
 764#[derive(Clone)]
 765struct SubPageLink {
 766    title: &'static str,
 767    files: FileMask,
 768    render: Arc<
 769        dyn Fn(&mut SettingsWindow, &mut Window, &mut Context<SettingsWindow>) -> AnyElement
 770            + 'static
 771            + Send
 772            + Sync,
 773    >,
 774}
 775
 776impl PartialEq for SubPageLink {
 777    fn eq(&self, other: &Self) -> bool {
 778        self.title == other.title
 779    }
 780}
 781
 782#[allow(unused)]
 783#[derive(Clone, PartialEq)]
 784enum SettingsUiFile {
 785    User,                                // Uses all settings.
 786    Project((WorktreeId, Arc<RelPath>)), // Has a special name, and special set of settings
 787    Server(&'static str),                // Uses a special name, and the user settings
 788}
 789
 790impl SettingsUiFile {
 791    fn is_server(&self) -> bool {
 792        matches!(self, SettingsUiFile::Server(_))
 793    }
 794
 795    fn worktree_id(&self) -> Option<WorktreeId> {
 796        match self {
 797            SettingsUiFile::User => None,
 798            SettingsUiFile::Project((worktree_id, _)) => Some(*worktree_id),
 799            SettingsUiFile::Server(_) => None,
 800        }
 801    }
 802
 803    fn from_settings(file: settings::SettingsFile) -> Option<Self> {
 804        Some(match file {
 805            settings::SettingsFile::User => SettingsUiFile::User,
 806            settings::SettingsFile::Project(location) => SettingsUiFile::Project(location),
 807            settings::SettingsFile::Server => SettingsUiFile::Server("todo: server name"),
 808            settings::SettingsFile::Default => return None,
 809        })
 810    }
 811
 812    fn to_settings(&self) -> settings::SettingsFile {
 813        match self {
 814            SettingsUiFile::User => settings::SettingsFile::User,
 815            SettingsUiFile::Project(location) => settings::SettingsFile::Project(location.clone()),
 816            SettingsUiFile::Server(_) => settings::SettingsFile::Server,
 817        }
 818    }
 819
 820    fn mask(&self) -> FileMask {
 821        match self {
 822            SettingsUiFile::User => USER,
 823            SettingsUiFile::Project(_) => LOCAL,
 824            SettingsUiFile::Server(_) => SERVER,
 825        }
 826    }
 827}
 828
 829impl SettingsWindow {
 830    pub fn new(
 831        original_window: Option<WindowHandle<Workspace>>,
 832        window: &mut Window,
 833        cx: &mut Context<Self>,
 834    ) -> Self {
 835        let font_family_cache = theme::FontFamilyCache::global(cx);
 836
 837        cx.spawn(async move |this, cx| {
 838            font_family_cache.prefetch(cx).await;
 839            this.update(cx, |_, cx| {
 840                cx.notify();
 841            })
 842        })
 843        .detach();
 844
 845        let current_file = SettingsUiFile::User;
 846        let search_bar = cx.new(|cx| {
 847            let mut editor = Editor::single_line(window, cx);
 848            editor.set_placeholder_text("Search settings…", window, cx);
 849            editor
 850        });
 851
 852        cx.subscribe(&search_bar, |this, _, event: &EditorEvent, cx| {
 853            let EditorEvent::Edited { transaction_id: _ } = event else {
 854                return;
 855            };
 856
 857            this.update_matches(cx);
 858        })
 859        .detach();
 860
 861        cx.observe_global_in::<SettingsStore>(window, move |this, _, cx| {
 862            this.fetch_files(cx);
 863            cx.notify();
 864        })
 865        .detach();
 866
 867        let mut this = Self {
 868            original_window,
 869            worktree_root_dirs: HashMap::default(),
 870            files: vec![],
 871            current_file: current_file,
 872            pages: vec![],
 873            navbar_entries: vec![],
 874            navbar_entry: 0,
 875            list_handle: UniformListScrollHandle::default(),
 876            search_bar,
 877            search_task: None,
 878            search_matches: vec![],
 879            scroll_handle: ScrollHandle::new(),
 880            focus_handle: cx.focus_handle(),
 881            navbar_focus_handle: cx
 882                .focus_handle()
 883                .tab_index(NAVBAR_CONTAINER_TAB_INDEX)
 884                .tab_stop(false),
 885            content_focus_handle: cx
 886                .focus_handle()
 887                .tab_index(CONTENT_CONTAINER_TAB_INDEX)
 888                .tab_stop(false),
 889            files_focus_handle: cx.focus_handle().tab_stop(false),
 890        };
 891
 892        this.fetch_files(cx);
 893        this.build_ui(cx);
 894
 895        this.search_bar.update(cx, |editor, cx| {
 896            editor.focus_handle(cx).focus(window);
 897        });
 898
 899        this
 900    }
 901
 902    fn toggle_navbar_entry(&mut self, ix: usize) {
 903        // We can only toggle root entries
 904        if !self.navbar_entries[ix].is_root {
 905            return;
 906        }
 907
 908        let toggle_page_index = self.page_index_from_navbar_index(ix);
 909        let selected_page_index = self.page_index_from_navbar_index(self.navbar_entry);
 910
 911        let expanded = &mut self.navbar_entries[ix].expanded;
 912        *expanded = !*expanded;
 913        // if currently selected page is a child of the parent page we are folding,
 914        // set the current page to the parent page
 915        if !*expanded && selected_page_index == toggle_page_index {
 916            self.navbar_entry = ix;
 917        }
 918    }
 919
 920    fn build_navbar(&mut self) {
 921        let mut prev_navbar_state = HashMap::new();
 922        let mut root_entry = "";
 923        let mut prev_selected_entry = None;
 924        for (index, entry) in self.navbar_entries.iter().enumerate() {
 925            let sub_entry_title;
 926            if entry.is_root {
 927                sub_entry_title = None;
 928                root_entry = entry.title;
 929            } else {
 930                sub_entry_title = Some(entry.title);
 931            }
 932            let key = (root_entry, sub_entry_title);
 933            if index == self.navbar_entry {
 934                prev_selected_entry = Some(key);
 935            }
 936            prev_navbar_state.insert(key, entry.expanded);
 937        }
 938
 939        let mut navbar_entries = Vec::with_capacity(self.navbar_entries.len());
 940        for (page_index, page) in self.pages.iter().enumerate() {
 941            navbar_entries.push(NavBarEntry {
 942                title: page.title,
 943                is_root: true,
 944                expanded: false,
 945                page_index,
 946                item_index: None,
 947            });
 948
 949            for (item_index, item) in page.items.iter().enumerate() {
 950                let SettingsPageItem::SectionHeader(title) = item else {
 951                    continue;
 952                };
 953                navbar_entries.push(NavBarEntry {
 954                    title,
 955                    is_root: false,
 956                    expanded: false,
 957                    page_index,
 958                    item_index: Some(item_index),
 959                });
 960            }
 961        }
 962
 963        let mut root_entry = "";
 964        let mut found_nav_entry = false;
 965        for (index, entry) in navbar_entries.iter_mut().enumerate() {
 966            let sub_entry_title;
 967            if entry.is_root {
 968                root_entry = entry.title;
 969                sub_entry_title = None;
 970            } else {
 971                sub_entry_title = Some(entry.title);
 972            };
 973            let key = (root_entry, sub_entry_title);
 974            if Some(key) == prev_selected_entry {
 975                self.navbar_entry = index;
 976                found_nav_entry = true;
 977            }
 978            entry.expanded = *prev_navbar_state.get(&key).unwrap_or(&false);
 979        }
 980        if !found_nav_entry {
 981            self.navbar_entry = 0;
 982        }
 983        self.navbar_entries = navbar_entries;
 984    }
 985
 986    fn visible_navbar_entries(&self) -> impl Iterator<Item = (usize, &NavBarEntry)> {
 987        let mut index = 0;
 988        let entries = &self.navbar_entries;
 989        let search_matches = &self.search_matches;
 990        std::iter::from_fn(move || {
 991            while index < entries.len() {
 992                let entry = &entries[index];
 993                let included_in_search = if let Some(item_index) = entry.item_index {
 994                    search_matches[entry.page_index][item_index]
 995                } else {
 996                    search_matches[entry.page_index].iter().any(|b| *b)
 997                        || search_matches[entry.page_index].is_empty()
 998                };
 999                if included_in_search {
1000                    break;
1001                }
1002                index += 1;
1003            }
1004            if index >= self.navbar_entries.len() {
1005                return None;
1006            }
1007            let entry = &entries[index];
1008            let entry_index = index;
1009
1010            index += 1;
1011            if entry.is_root && !entry.expanded {
1012                while index < entries.len() {
1013                    if entries[index].is_root {
1014                        break;
1015                    }
1016                    index += 1;
1017                }
1018            }
1019
1020            return Some((entry_index, entry));
1021        })
1022    }
1023
1024    fn filter_matches_to_file(&mut self) {
1025        let current_file = self.current_file.mask();
1026        for (page, page_filter) in std::iter::zip(&self.pages, &mut self.search_matches) {
1027            let mut header_index = 0;
1028            let mut any_found_since_last_header = true;
1029
1030            for (index, item) in page.items.iter().enumerate() {
1031                match item {
1032                    SettingsPageItem::SectionHeader(_) => {
1033                        if !any_found_since_last_header {
1034                            page_filter[header_index] = false;
1035                        }
1036                        header_index = index;
1037                        any_found_since_last_header = false;
1038                    }
1039                    SettingsPageItem::SettingItem(setting_item) => {
1040                        if !setting_item.files.contains(current_file) {
1041                            page_filter[index] = false;
1042                        } else {
1043                            any_found_since_last_header = true;
1044                        }
1045                    }
1046                    SettingsPageItem::SubPageLink(sub_page_link) => {
1047                        if !sub_page_link.files.contains(current_file) {
1048                            page_filter[index] = false;
1049                        } else {
1050                            any_found_since_last_header = true;
1051                        }
1052                    }
1053                }
1054            }
1055            if let Some(last_header) = page_filter.get_mut(header_index)
1056                && !any_found_since_last_header
1057            {
1058                *last_header = false;
1059            }
1060        }
1061    }
1062
1063    fn update_matches(&mut self, cx: &mut Context<SettingsWindow>) {
1064        self.search_task.take();
1065        let query = self.search_bar.read(cx).text(cx);
1066        if query.is_empty() {
1067            for page in &mut self.search_matches {
1068                page.fill(true);
1069            }
1070            self.filter_matches_to_file();
1071            cx.notify();
1072            return;
1073        }
1074
1075        struct ItemKey {
1076            page_index: usize,
1077            header_index: usize,
1078            item_index: usize,
1079        }
1080        let mut key_lut: Vec<ItemKey> = vec![];
1081        let mut candidates = Vec::default();
1082
1083        for (page_index, page) in self.pages.iter().enumerate() {
1084            let mut header_index = 0;
1085            for (item_index, item) in page.items.iter().enumerate() {
1086                let key_index = key_lut.len();
1087                match item {
1088                    SettingsPageItem::SettingItem(item) => {
1089                        candidates.push(StringMatchCandidate::new(key_index, item.title));
1090                        candidates.push(StringMatchCandidate::new(key_index, item.description));
1091                    }
1092                    SettingsPageItem::SectionHeader(header) => {
1093                        candidates.push(StringMatchCandidate::new(key_index, header));
1094                        header_index = item_index;
1095                    }
1096                    SettingsPageItem::SubPageLink(sub_page_link) => {
1097                        candidates.push(StringMatchCandidate::new(key_index, sub_page_link.title));
1098                    }
1099                }
1100                key_lut.push(ItemKey {
1101                    page_index,
1102                    header_index,
1103                    item_index,
1104                });
1105            }
1106        }
1107        let atomic_bool = AtomicBool::new(false);
1108
1109        self.search_task = Some(cx.spawn(async move |this, cx| {
1110            let string_matches = fuzzy::match_strings(
1111                candidates.as_slice(),
1112                &query,
1113                false,
1114                true,
1115                candidates.len(),
1116                &atomic_bool,
1117                cx.background_executor().clone(),
1118            );
1119            let string_matches = string_matches.await;
1120
1121            this.update(cx, |this, cx| {
1122                for page in &mut this.search_matches {
1123                    page.fill(false);
1124                }
1125
1126                for string_match in string_matches {
1127                    let ItemKey {
1128                        page_index,
1129                        header_index,
1130                        item_index,
1131                    } = key_lut[string_match.candidate_id];
1132                    let page = &mut this.search_matches[page_index];
1133                    page[header_index] = true;
1134                    page[item_index] = true;
1135                }
1136                this.filter_matches_to_file();
1137                let first_navbar_entry_index = this
1138                    .visible_navbar_entries()
1139                    .next()
1140                    .map(|e| e.0)
1141                    .unwrap_or(0);
1142                this.navbar_entry = first_navbar_entry_index;
1143                cx.notify();
1144            })
1145            .ok();
1146        }));
1147    }
1148
1149    fn build_search_matches(&mut self) {
1150        self.search_matches = self
1151            .pages
1152            .iter()
1153            .map(|page| vec![true; page.items.len()])
1154            .collect::<Vec<_>>();
1155    }
1156
1157    fn build_ui(&mut self, cx: &mut Context<SettingsWindow>) {
1158        if self.pages.is_empty() {
1159            self.pages = page_data::settings_data();
1160        }
1161        self.build_search_matches();
1162        self.build_navbar();
1163
1164        self.update_matches(cx);
1165
1166        cx.notify();
1167    }
1168
1169    fn fetch_files(&mut self, cx: &mut Context<SettingsWindow>) {
1170        self.worktree_root_dirs.clear();
1171        let prev_files = self.files.clone();
1172        let settings_store = cx.global::<SettingsStore>();
1173        let mut ui_files = vec![];
1174        let all_files = settings_store.get_all_files();
1175        for file in all_files {
1176            let Some(settings_ui_file) = SettingsUiFile::from_settings(file) else {
1177                continue;
1178            };
1179            if settings_ui_file.is_server() {
1180                continue;
1181            }
1182
1183            if let Some(worktree_id) = settings_ui_file.worktree_id() {
1184                let directory_name = all_projects(cx)
1185                    .find_map(|project| project.read(cx).worktree_for_id(worktree_id, cx))
1186                    .and_then(|worktree| worktree.read(cx).root_dir())
1187                    .and_then(|root_dir| {
1188                        root_dir
1189                            .file_name()
1190                            .map(|os_string| os_string.to_string_lossy().to_string())
1191                    });
1192
1193                let Some(directory_name) = directory_name else {
1194                    log::error!(
1195                        "No directory name found for settings file at worktree ID: {}",
1196                        worktree_id
1197                    );
1198                    continue;
1199                };
1200
1201                self.worktree_root_dirs.insert(worktree_id, directory_name);
1202            }
1203
1204            let focus_handle = prev_files
1205                .iter()
1206                .find_map(|(prev_file, handle)| {
1207                    (prev_file == &settings_ui_file).then(|| handle.clone())
1208                })
1209                .unwrap_or_else(|| cx.focus_handle());
1210            ui_files.push((settings_ui_file, focus_handle));
1211        }
1212        ui_files.reverse();
1213        self.files = ui_files;
1214        let current_file_still_exists = self
1215            .files
1216            .iter()
1217            .any(|(file, _)| file == &self.current_file);
1218        if !current_file_still_exists {
1219            self.change_file(0, cx);
1220        }
1221    }
1222
1223    fn change_file(&mut self, ix: usize, cx: &mut Context<SettingsWindow>) {
1224        if ix >= self.files.len() {
1225            self.current_file = SettingsUiFile::User;
1226            return;
1227        }
1228        if self.files[ix].0 == self.current_file {
1229            return;
1230        }
1231        self.current_file = self.files[ix].0.clone();
1232        // self.navbar_entry = 0;
1233        self.build_ui(cx);
1234    }
1235
1236    fn render_files_header(
1237        &self,
1238        _window: &mut Window,
1239        cx: &mut Context<SettingsWindow>,
1240    ) -> impl IntoElement {
1241        h_flex()
1242            .w_full()
1243            .gap_1()
1244            .justify_between()
1245            .child(
1246                h_flex()
1247                    .id("file_buttons_container")
1248                    .w_64() // Temporary fix until long-term solution is a fixed set of buttons representing a file location (User, Project, and Remote)
1249                    .gap_1()
1250                    .overflow_x_scroll()
1251                    .children(
1252                        self.files
1253                            .iter()
1254                            .enumerate()
1255                            .map(|(ix, (file, focus_handle))| {
1256                                Button::new(
1257                                    ix,
1258                                    self.display_name(&file)
1259                                        .expect("Files should always have a name"),
1260                                )
1261                                .toggle_state(file == &self.current_file)
1262                                .selected_style(ButtonStyle::Tinted(ui::TintColor::Accent))
1263                                .track_focus(focus_handle)
1264                                .on_click(cx.listener(
1265                                    move |this, evt: &gpui::ClickEvent, window, cx| {
1266                                        this.change_file(ix, cx);
1267                                        if evt.is_keyboard() {
1268                                            this.focus_first_nav_item(window, cx);
1269                                        }
1270                                    },
1271                                ))
1272                            }),
1273                    ),
1274            )
1275            .child(
1276                Button::new(
1277                    "edit-in-json",
1278                    format!("Edit in {}", self.file_location_str()),
1279                )
1280                .style(ButtonStyle::Outlined)
1281                .on_click(cx.listener(|this, _, _, cx| {
1282                    this.open_current_settings_file(cx);
1283                })),
1284            )
1285    }
1286
1287    pub(crate) fn display_name(&self, file: &SettingsUiFile) -> Option<String> {
1288        match file {
1289            SettingsUiFile::User => Some("User".to_string()),
1290            SettingsUiFile::Project((worktree_id, path)) => self
1291                .worktree_root_dirs
1292                .get(&worktree_id)
1293                .map(|directory_name| {
1294                    let path_style = PathStyle::local();
1295                    if path.is_empty() {
1296                        directory_name.clone()
1297                    } else {
1298                        format!(
1299                            "{}{}{}",
1300                            directory_name,
1301                            path_style.separator(),
1302                            path.display(path_style)
1303                        )
1304                    }
1305                }),
1306            SettingsUiFile::Server(file) => Some(file.to_string()),
1307        }
1308    }
1309
1310    fn file_location_str(&self) -> String {
1311        match &self.current_file {
1312            SettingsUiFile::User => "settings.json".to_string(),
1313            SettingsUiFile::Project((worktree_id, path)) => self
1314                .worktree_root_dirs
1315                .get(&worktree_id)
1316                .map(|directory_name| {
1317                    let path_style = PathStyle::local();
1318                    let file_path = path.join(paths::local_settings_file_relative_path());
1319                    format!(
1320                        "{}{}{}",
1321                        directory_name,
1322                        path_style.separator(),
1323                        file_path.display(path_style)
1324                    )
1325                })
1326                .expect("Current file should always be present in root dir map"),
1327            SettingsUiFile::Server(file) => file.to_string(),
1328        }
1329    }
1330
1331    fn render_search(&self, _window: &mut Window, cx: &mut App) -> Div {
1332        h_flex()
1333            .py_1()
1334            .px_1p5()
1335            .mb_3()
1336            .gap_1p5()
1337            .rounded_sm()
1338            .bg(cx.theme().colors().editor_background)
1339            .border_1()
1340            .border_color(cx.theme().colors().border)
1341            .child(Icon::new(IconName::MagnifyingGlass).color(Color::Muted))
1342            .child(self.search_bar.clone())
1343    }
1344
1345    fn render_nav(
1346        &self,
1347        window: &mut Window,
1348        cx: &mut Context<SettingsWindow>,
1349    ) -> impl IntoElement {
1350        let visible_count = self.visible_navbar_entries().count();
1351
1352        let focus_keybind_label = if self.navbar_focus_handle.contains_focused(window, cx) {
1353            "Focus Content"
1354        } else {
1355            "Focus Navbar"
1356        };
1357
1358        v_flex()
1359            .w_64()
1360            .p_2p5()
1361            .pt_10()
1362            .flex_none()
1363            .border_r_1()
1364            .border_color(cx.theme().colors().border)
1365            .bg(cx.theme().colors().panel_background)
1366            .child(self.render_search(window, cx))
1367            .child(
1368                v_flex()
1369                    .size_full()
1370                    .track_focus(&self.navbar_focus_handle)
1371                    .tab_group()
1372                    .tab_index(NAVBAR_GROUP_TAB_INDEX)
1373                    .child(
1374                        uniform_list(
1375                            "settings-ui-nav-bar",
1376                            visible_count,
1377                            cx.processor(move |this, range: Range<usize>, _, cx| {
1378                                let entries: Vec<_> = this.visible_navbar_entries().collect();
1379                                range
1380                                    .filter_map(|ix| entries.get(ix).copied())
1381                                    .map(|(ix, entry)| {
1382                                        TreeViewItem::new(
1383                                            ("settings-ui-navbar-entry", ix),
1384                                            entry.title,
1385                                        )
1386                                        .tab_index(0)
1387                                        .root_item(entry.is_root)
1388                                        .toggle_state(this.is_navbar_entry_selected(ix))
1389                                        .when(entry.is_root, |item| {
1390                                            item.expanded(entry.expanded).on_toggle(cx.listener(
1391                                                move |this, _, _, cx| {
1392                                                    this.toggle_navbar_entry(ix);
1393                                                    cx.notify();
1394                                                },
1395                                            ))
1396                                        })
1397                                        .on_click(cx.listener(move |this, _, _, cx| {
1398                                            this.navbar_entry = ix;
1399
1400                                            if !this.navbar_entries[ix].is_root {
1401                                                let mut selected_page_ix = ix;
1402
1403                                                while !this.navbar_entries[selected_page_ix].is_root
1404                                                {
1405                                                    selected_page_ix -= 1;
1406                                                }
1407
1408                                                let section_header = ix - selected_page_ix;
1409
1410                                                if let Some(section_index) = this
1411                                                    .page_items()
1412                                                    .enumerate()
1413                                                    .filter(|item| {
1414                                                        matches!(
1415                                                            item.1,
1416                                                            SettingsPageItem::SectionHeader(_)
1417                                                        )
1418                                                    })
1419                                                    .take(section_header)
1420                                                    .last()
1421                                                    .map(|pair| pair.0)
1422                                                {
1423                                                    this.scroll_handle
1424                                                        .scroll_to_top_of_item(section_index);
1425                                                }
1426                                            }
1427
1428                                            cx.notify();
1429                                        }))
1430                                        .into_any_element()
1431                                    })
1432                                    .collect()
1433                            }),
1434                        )
1435                        .size_full()
1436                        .track_scroll(self.list_handle.clone()),
1437                    )
1438                    .vertical_scrollbar_for(self.list_handle.clone(), window, cx),
1439            )
1440            .child(
1441                h_flex()
1442                    .w_full()
1443                    .p_2()
1444                    .pb_0p5()
1445                    .flex_none()
1446                    .border_t_1()
1447                    .border_color(cx.theme().colors().border_variant)
1448                    .children(
1449                        KeyBinding::for_action(&ToggleFocusNav, window, cx).map(|this| {
1450                            KeybindingHint::new(
1451                                this,
1452                                cx.theme().colors().surface_background.opacity(0.5),
1453                            )
1454                            .suffix(focus_keybind_label)
1455                        }),
1456                    ),
1457            )
1458    }
1459
1460    fn focus_first_nav_item(&self, window: &mut Window, cx: &mut Context<Self>) {
1461        self.navbar_focus_handle.focus(window);
1462        window.focus_next();
1463        cx.notify();
1464    }
1465
1466    fn focus_first_content_item(&self, window: &mut Window, cx: &mut Context<Self>) {
1467        self.content_focus_handle.focus(window);
1468        window.focus_next();
1469        cx.notify();
1470    }
1471
1472    fn page_items(&self) -> impl Iterator<Item = &SettingsPageItem> {
1473        let page_idx = self.current_page_index();
1474
1475        self.current_page()
1476            .items
1477            .iter()
1478            .enumerate()
1479            .filter_map(move |(item_index, item)| {
1480                self.search_matches[page_idx][item_index].then_some(item)
1481            })
1482    }
1483
1484    fn render_sub_page_breadcrumbs(&self) -> impl IntoElement {
1485        let mut items = vec![];
1486        items.push(self.current_page().title);
1487        items.extend(
1488            sub_page_stack()
1489                .iter()
1490                .flat_map(|page| [page.section_header, page.link.title]),
1491        );
1492
1493        let last = items.pop().unwrap();
1494        h_flex()
1495            .gap_1()
1496            .children(
1497                items
1498                    .into_iter()
1499                    .flat_map(|item| [item, "/"])
1500                    .map(|item| Label::new(item).color(Color::Muted)),
1501            )
1502            .child(Label::new(last))
1503    }
1504
1505    fn render_page_items<'a, Items: Iterator<Item = &'a SettingsPageItem>>(
1506        &self,
1507        items: Items,
1508        window: &mut Window,
1509        cx: &mut Context<SettingsWindow>,
1510    ) -> impl IntoElement {
1511        let mut page_content = v_flex()
1512            .id("settings-ui-page")
1513            .size_full()
1514            .gap_4()
1515            .overflow_y_scroll()
1516            .track_scroll(&self.scroll_handle);
1517
1518        let items: Vec<_> = items.collect();
1519        let items_len = items.len();
1520        let mut section_header = None;
1521
1522        let has_active_search = !self.search_bar.read(cx).is_empty(cx);
1523        let has_no_results = items_len == 0 && has_active_search;
1524
1525        if has_no_results {
1526            let search_query = self.search_bar.read(cx).text(cx);
1527            page_content = page_content.child(
1528                v_flex()
1529                    .size_full()
1530                    .items_center()
1531                    .justify_center()
1532                    .gap_1()
1533                    .child(div().child("No Results"))
1534                    .child(
1535                        div()
1536                            .text_sm()
1537                            .text_color(cx.theme().colors().text_muted)
1538                            .child(format!("No settings match \"{}\"", search_query)),
1539                    ),
1540            )
1541        } else {
1542            let last_non_header_index = items
1543                .iter()
1544                .enumerate()
1545                .rev()
1546                .find(|(_, item)| !matches!(item, SettingsPageItem::SectionHeader(_)))
1547                .map(|(index, _)| index);
1548
1549            page_content =
1550                page_content.children(items.clone().into_iter().enumerate().map(|(index, item)| {
1551                    let no_bottom_border = items
1552                        .get(index + 1)
1553                        .map(|next_item| matches!(next_item, SettingsPageItem::SectionHeader(_)))
1554                        .unwrap_or(false);
1555                    let is_last = Some(index) == last_non_header_index;
1556
1557                    if let SettingsPageItem::SectionHeader(header) = item {
1558                        section_header = Some(*header);
1559                    }
1560                    item.render(
1561                        self,
1562                        section_header.expect("All items rendered after a section header"),
1563                        no_bottom_border || is_last,
1564                        window,
1565                        cx,
1566                    )
1567                }))
1568        }
1569        page_content
1570    }
1571
1572    fn render_page(
1573        &mut self,
1574        window: &mut Window,
1575        cx: &mut Context<SettingsWindow>,
1576    ) -> impl IntoElement {
1577        let page_header;
1578        let page_content;
1579
1580        if sub_page_stack().len() == 0 {
1581            page_header = self.render_files_header(window, cx).into_any_element();
1582
1583            page_content = self
1584                .render_page_items(self.page_items(), window, cx)
1585                .into_any_element();
1586        } else {
1587            page_header = h_flex()
1588                .ml_neg_1p5()
1589                .gap_1()
1590                .child(
1591                    IconButton::new("back-btn", IconName::ArrowLeft)
1592                        .icon_size(IconSize::Small)
1593                        .shape(IconButtonShape::Square)
1594                        .on_click(cx.listener(|this, _, _, cx| {
1595                            this.pop_sub_page(cx);
1596                        })),
1597                )
1598                .child(self.render_sub_page_breadcrumbs())
1599                .into_any_element();
1600
1601            let active_page_render_fn = sub_page_stack().last().unwrap().link.render.clone();
1602            page_content = (active_page_render_fn)(self, window, cx);
1603        }
1604
1605        return v_flex()
1606            .w_full()
1607            .pt_4()
1608            .pb_6()
1609            .px_6()
1610            .gap_4()
1611            .track_focus(&self.content_focus_handle)
1612            .bg(cx.theme().colors().editor_background)
1613            .vertical_scrollbar_for(self.scroll_handle.clone(), window, cx)
1614            .child(page_header)
1615            .child(
1616                div()
1617                    .size_full()
1618                    .track_focus(&self.content_focus_handle)
1619                    .tab_group()
1620                    .tab_index(CONTENT_GROUP_TAB_INDEX)
1621                    .child(page_content),
1622            );
1623    }
1624
1625    fn open_current_settings_file(&mut self, cx: &mut Context<Self>) {
1626        match &self.current_file {
1627            SettingsUiFile::User => {
1628                let Some(original_window) = self.original_window else {
1629                    return;
1630                };
1631                original_window
1632                    .update(cx, |workspace, window, cx| {
1633                        workspace
1634                            .with_local_workspace(window, cx, |workspace, window, cx| {
1635                                let create_task = workspace.project().update(cx, |project, cx| {
1636                                    project.find_or_create_worktree(
1637                                        paths::config_dir().as_path(),
1638                                        false,
1639                                        cx,
1640                                    )
1641                                });
1642                                let open_task = workspace.open_paths(
1643                                    vec![paths::settings_file().to_path_buf()],
1644                                    OpenOptions {
1645                                        visible: Some(OpenVisible::None),
1646                                        ..Default::default()
1647                                    },
1648                                    None,
1649                                    window,
1650                                    cx,
1651                                );
1652
1653                                cx.spawn_in(window, async move |workspace, cx| {
1654                                    create_task.await.ok();
1655                                    open_task.await;
1656
1657                                    workspace.update_in(cx, |_, window, cx| {
1658                                        window.activate_window();
1659                                        cx.notify();
1660                                    })
1661                                })
1662                                .detach();
1663                            })
1664                            .detach();
1665                    })
1666                    .ok();
1667            }
1668            SettingsUiFile::Project((worktree_id, path)) => {
1669                let mut corresponding_workspace: Option<WindowHandle<Workspace>> = None;
1670                let settings_path = path.join(paths::local_settings_file_relative_path());
1671                let Some(app_state) = workspace::AppState::global(cx).upgrade() else {
1672                    return;
1673                };
1674                for workspace in app_state.workspace_store.read(cx).workspaces() {
1675                    let contains_settings_file = workspace
1676                        .read_with(cx, |workspace, cx| {
1677                            workspace.project().read(cx).contains_local_settings_file(
1678                                *worktree_id,
1679                                settings_path.as_ref(),
1680                                cx,
1681                            )
1682                        })
1683                        .ok();
1684                    if Some(true) == contains_settings_file {
1685                        corresponding_workspace = Some(*workspace);
1686
1687                        break;
1688                    }
1689                }
1690
1691                let Some(corresponding_workspace) = corresponding_workspace else {
1692                    log::error!(
1693                        "No corresponding workspace found for settings file {}",
1694                        settings_path.as_std_path().display()
1695                    );
1696
1697                    return;
1698                };
1699
1700                // TODO: move zed::open_local_file() APIs to this crate, and
1701                // re-implement the "initial_contents" behavior
1702                corresponding_workspace
1703                    .update(cx, |workspace, window, cx| {
1704                        let open_task = workspace.open_path(
1705                            (*worktree_id, settings_path.clone()),
1706                            None,
1707                            true,
1708                            window,
1709                            cx,
1710                        );
1711
1712                        cx.spawn_in(window, async move |workspace, cx| {
1713                            if open_task.await.log_err().is_some() {
1714                                workspace
1715                                    .update_in(cx, |_, window, cx| {
1716                                        window.activate_window();
1717                                        cx.notify();
1718                                    })
1719                                    .ok();
1720                            }
1721                        })
1722                        .detach();
1723                    })
1724                    .ok();
1725            }
1726            SettingsUiFile::Server(_) => {
1727                return;
1728            }
1729        };
1730    }
1731
1732    fn current_page_index(&self) -> usize {
1733        self.page_index_from_navbar_index(self.navbar_entry)
1734    }
1735
1736    fn current_page(&self) -> &SettingsPage {
1737        &self.pages[self.current_page_index()]
1738    }
1739
1740    fn page_index_from_navbar_index(&self, index: usize) -> usize {
1741        if self.navbar_entries.is_empty() {
1742            return 0;
1743        }
1744
1745        self.navbar_entries[index].page_index
1746    }
1747
1748    fn is_navbar_entry_selected(&self, ix: usize) -> bool {
1749        ix == self.navbar_entry
1750    }
1751
1752    fn push_sub_page(
1753        &mut self,
1754        sub_page_link: SubPageLink,
1755        section_header: &'static str,
1756        cx: &mut Context<SettingsWindow>,
1757    ) {
1758        sub_page_stack_mut().push(SubPage {
1759            link: sub_page_link,
1760            section_header,
1761        });
1762        cx.notify();
1763    }
1764
1765    fn pop_sub_page(&mut self, cx: &mut Context<SettingsWindow>) {
1766        sub_page_stack_mut().pop();
1767        cx.notify();
1768    }
1769
1770    fn focus_file_at_index(&mut self, index: usize, window: &mut Window) {
1771        if let Some((_, handle)) = self.files.get(index) {
1772            handle.focus(window);
1773        }
1774    }
1775
1776    fn focused_file_index(&self, window: &Window, cx: &Context<Self>) -> usize {
1777        if self.files_focus_handle.contains_focused(window, cx)
1778            && let Some(index) = self
1779                .files
1780                .iter()
1781                .position(|(_, handle)| handle.is_focused(window))
1782        {
1783            return index;
1784        }
1785        if let Some(current_file_index) = self
1786            .files
1787            .iter()
1788            .position(|(file, _)| file == &self.current_file)
1789        {
1790            return current_file_index;
1791        }
1792        0
1793    }
1794}
1795
1796impl Render for SettingsWindow {
1797    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1798        let ui_font = theme::setup_ui_font(window, cx);
1799
1800        div()
1801            .id("settings-window")
1802            .key_context("SettingsWindow")
1803            .track_focus(&self.focus_handle)
1804            .on_action(cx.listener(|this, _: &OpenCurrentFile, _, cx| {
1805                this.open_current_settings_file(cx);
1806            }))
1807            .on_action(|_: &Minimize, window, _cx| {
1808                window.minimize_window();
1809            })
1810            .on_action(cx.listener(|this, _: &search::FocusSearch, window, cx| {
1811                this.search_bar.focus_handle(cx).focus(window);
1812            }))
1813            .on_action(cx.listener(|this, _: &ToggleFocusNav, window, cx| {
1814                if this.navbar_focus_handle.contains_focused(window, cx) {
1815                    this.focus_first_content_item(window, cx);
1816                } else {
1817                    this.focus_first_nav_item(window, cx);
1818                }
1819            }))
1820            .on_action(
1821                cx.listener(|this, FocusFile(file_index): &FocusFile, window, _| {
1822                    this.focus_file_at_index(*file_index as usize, window);
1823                }),
1824            )
1825            .on_action(cx.listener(|this, _: &FocusNextFile, window, cx| {
1826                let next_index = usize::min(
1827                    this.focused_file_index(window, cx) + 1,
1828                    this.files.len().saturating_sub(1),
1829                );
1830                this.focus_file_at_index(next_index, window);
1831            }))
1832            .on_action(cx.listener(|this, _: &FocusPreviousFile, window, cx| {
1833                let prev_index = this.focused_file_index(window, cx).saturating_sub(1);
1834                this.focus_file_at_index(prev_index, window);
1835            }))
1836            .on_action(|_: &menu::SelectNext, window, _| {
1837                window.focus_next();
1838            })
1839            .on_action(|_: &menu::SelectPrevious, window, _| {
1840                window.focus_prev();
1841            })
1842            .flex()
1843            .flex_row()
1844            .size_full()
1845            .font(ui_font)
1846            .bg(cx.theme().colors().background)
1847            .text_color(cx.theme().colors().text)
1848            .child(self.render_nav(window, cx))
1849            .child(self.render_page(window, cx))
1850    }
1851}
1852
1853fn all_projects(cx: &App) -> impl Iterator<Item = Entity<project::Project>> {
1854    workspace::AppState::global(cx)
1855        .upgrade()
1856        .map(|app_state| {
1857            app_state
1858                .workspace_store
1859                .read(cx)
1860                .workspaces()
1861                .iter()
1862                .filter_map(|workspace| Some(workspace.read(cx).ok()?.project().clone()))
1863        })
1864        .into_iter()
1865        .flatten()
1866}
1867
1868fn update_settings_file(
1869    file: SettingsUiFile,
1870    cx: &mut App,
1871    update: impl 'static + Send + FnOnce(&mut SettingsContent, &App),
1872) -> Result<()> {
1873    match file {
1874        SettingsUiFile::Project((worktree_id, rel_path)) => {
1875            let rel_path = rel_path.join(paths::local_settings_file_relative_path());
1876            let project = all_projects(cx).find(|project| {
1877                project.read_with(cx, |project, cx| {
1878                    project.contains_local_settings_file(worktree_id, &rel_path, cx)
1879                })
1880            });
1881            let Some(project) = project else {
1882                anyhow::bail!(
1883                    "Could not find worktree containing settings file: {}",
1884                    &rel_path.display(PathStyle::local())
1885                );
1886            };
1887            project.update(cx, |project, cx| {
1888                project.update_local_settings_file(worktree_id, rel_path, cx, update);
1889            });
1890            return Ok(());
1891        }
1892        SettingsUiFile::User => {
1893            // todo(settings_ui) error?
1894            SettingsStore::global(cx).update_settings_file(<dyn fs::Fs>::global(cx), update);
1895            Ok(())
1896        }
1897        SettingsUiFile::Server(_) => unimplemented!(),
1898    }
1899}
1900
1901fn render_text_field<T: From<String> + Into<String> + AsRef<str> + Clone>(
1902    field: SettingField<T>,
1903    file: SettingsUiFile,
1904    metadata: Option<&SettingsFieldMetadata>,
1905    cx: &mut App,
1906) -> AnyElement {
1907    let (_, initial_text) =
1908        SettingsStore::global(cx).get_value_from_file(file.to_settings(), field.pick);
1909    let initial_text = initial_text.filter(|s| !s.as_ref().is_empty());
1910
1911    SettingsEditor::new()
1912        .tab_index(0)
1913        .when_some(initial_text, |editor, text| {
1914            editor.with_initial_text(text.as_ref().to_string())
1915        })
1916        .when_some(
1917            metadata.and_then(|metadata| metadata.placeholder),
1918            |editor, placeholder| editor.with_placeholder(placeholder),
1919        )
1920        .on_confirm({
1921            move |new_text, cx| {
1922                update_settings_file(file.clone(), cx, move |settings, _cx| {
1923                    *(field.pick_mut)(settings) = new_text.map(Into::into);
1924                })
1925                .log_err(); // todo(settings_ui) don't log err
1926            }
1927        })
1928        .into_any_element()
1929}
1930
1931fn render_toggle_button<B: Into<bool> + From<bool> + Copy>(
1932    field: SettingField<B>,
1933    file: SettingsUiFile,
1934    cx: &mut App,
1935) -> AnyElement {
1936    let (_, value) = SettingsStore::global(cx).get_value_from_file(file.to_settings(), field.pick);
1937
1938    let toggle_state = if value.copied().map_or(false, Into::into) {
1939        ToggleState::Selected
1940    } else {
1941        ToggleState::Unselected
1942    };
1943
1944    Switch::new("toggle_button", toggle_state)
1945        .color(ui::SwitchColor::Accent)
1946        .on_click({
1947            move |state, _window, cx| {
1948                let state = *state == ui::ToggleState::Selected;
1949                update_settings_file(file.clone(), cx, move |settings, _cx| {
1950                    *(field.pick_mut)(settings) = Some(state.into());
1951                })
1952                .log_err(); // todo(settings_ui) don't log err
1953            }
1954        })
1955        .tab_index(0_isize)
1956        .color(SwitchColor::Accent)
1957        .into_any_element()
1958}
1959
1960fn render_font_picker(
1961    field: SettingField<settings::FontFamilyName>,
1962    file: SettingsUiFile,
1963    window: &mut Window,
1964    cx: &mut App,
1965) -> AnyElement {
1966    let current_value = SettingsStore::global(cx)
1967        .get_value_from_file(file.to_settings(), field.pick)
1968        .1
1969        .cloned()
1970        .unwrap_or_else(|| SharedString::default().into());
1971
1972    let font_picker = cx.new(|cx| {
1973        ui_input::font_picker(
1974            current_value.clone().into(),
1975            move |font_name, cx| {
1976                update_settings_file(file.clone(), cx, move |settings, _cx| {
1977                    *(field.pick_mut)(settings) = Some(font_name.into());
1978                })
1979                .log_err(); // todo(settings_ui) don't log err
1980            },
1981            window,
1982            cx,
1983        )
1984    });
1985
1986    PopoverMenu::new("font-picker")
1987        .menu(move |_window, _cx| Some(font_picker.clone()))
1988        .trigger(
1989            Button::new("font-family-button", current_value)
1990                .tab_index(0_isize)
1991                .style(ButtonStyle::Outlined)
1992                .size(ButtonSize::Medium)
1993                .icon(IconName::ChevronUpDown)
1994                .icon_color(Color::Muted)
1995                .icon_size(IconSize::Small)
1996                .icon_position(IconPosition::End),
1997        )
1998        .anchor(gpui::Corner::TopLeft)
1999        .offset(gpui::Point {
2000            x: px(0.0),
2001            y: px(2.0),
2002        })
2003        .with_handle(ui::PopoverMenuHandle::default())
2004        .into_any_element()
2005}
2006
2007fn render_number_field<T: NumberFieldType + Send + Sync>(
2008    field: SettingField<T>,
2009    file: SettingsUiFile,
2010    window: &mut Window,
2011    cx: &mut App,
2012) -> AnyElement {
2013    let (_, value) = SettingsStore::global(cx).get_value_from_file(file.to_settings(), field.pick);
2014    let value = value.copied().unwrap_or_else(T::min_value);
2015    NumberField::new("numeric_stepper", value, window, cx)
2016        .on_change({
2017            move |value, _window, cx| {
2018                let value = *value;
2019                update_settings_file(file.clone(), cx, move |settings, _cx| {
2020                    *(field.pick_mut)(settings) = Some(value);
2021                })
2022                .log_err(); // todo(settings_ui) don't log err
2023            }
2024        })
2025        .into_any_element()
2026}
2027
2028fn render_dropdown<T>(
2029    field: SettingField<T>,
2030    file: SettingsUiFile,
2031    window: &mut Window,
2032    cx: &mut App,
2033) -> AnyElement
2034where
2035    T: strum::VariantArray + strum::VariantNames + Copy + PartialEq + Send + Sync + 'static,
2036{
2037    let variants = || -> &'static [T] { <T as strum::VariantArray>::VARIANTS };
2038    let labels = || -> &'static [&'static str] { <T as strum::VariantNames>::VARIANTS };
2039
2040    let (_, current_value) =
2041        SettingsStore::global(cx).get_value_from_file(file.to_settings(), field.pick);
2042    let current_value = current_value.copied().unwrap_or(variants()[0]);
2043
2044    let current_value_label =
2045        labels()[variants().iter().position(|v| *v == current_value).unwrap()];
2046
2047    DropdownMenu::new(
2048        "dropdown",
2049        current_value_label.to_title_case(),
2050        ContextMenu::build(window, cx, move |mut menu, _, _| {
2051            for (&value, &label) in std::iter::zip(variants(), labels()) {
2052                let file = file.clone();
2053                menu = menu.toggleable_entry(
2054                    label.to_title_case(),
2055                    value == current_value,
2056                    IconPosition::Start,
2057                    None,
2058                    move |_, cx| {
2059                        if value == current_value {
2060                            return;
2061                        }
2062                        update_settings_file(file.clone(), cx, move |settings, _cx| {
2063                            *(field.pick_mut)(settings) = Some(value);
2064                        })
2065                        .log_err(); // todo(settings_ui) don't log err
2066                    },
2067                );
2068            }
2069            menu
2070        }),
2071    )
2072    .trigger_size(ButtonSize::Medium)
2073    .style(DropdownStyle::Outlined)
2074    .offset(gpui::Point {
2075        x: px(0.0),
2076        y: px(2.0),
2077    })
2078    .tab_index(0)
2079    .into_any_element()
2080}
2081
2082#[cfg(test)]
2083mod test {
2084
2085    use super::*;
2086
2087    impl SettingsWindow {
2088        fn navbar_entry(&self) -> usize {
2089            self.navbar_entry
2090        }
2091
2092        fn new_builder(window: &mut Window, cx: &mut Context<Self>) -> Self {
2093            let mut this = Self::new(None, window, cx);
2094            this.navbar_entries.clear();
2095            this.pages.clear();
2096            this
2097        }
2098
2099        fn build(mut self) -> Self {
2100            self.build_search_matches();
2101            self.build_navbar();
2102            self
2103        }
2104
2105        fn add_page(
2106            mut self,
2107            title: &'static str,
2108            build_page: impl Fn(SettingsPage) -> SettingsPage,
2109        ) -> Self {
2110            let page = SettingsPage {
2111                title,
2112                items: Vec::default(),
2113            };
2114
2115            self.pages.push(build_page(page));
2116            self
2117        }
2118
2119        fn search(&mut self, search_query: &str, window: &mut Window, cx: &mut Context<Self>) {
2120            self.search_task.take();
2121            self.search_bar.update(cx, |editor, cx| {
2122                editor.set_text(search_query, window, cx);
2123            });
2124            self.update_matches(cx);
2125        }
2126
2127        fn assert_search_results(&self, other: &Self) {
2128            // page index could be different because of filtered out pages
2129            #[derive(Debug, PartialEq)]
2130            struct EntryMinimal {
2131                is_root: bool,
2132                title: &'static str,
2133            }
2134            pretty_assertions::assert_eq!(
2135                other
2136                    .visible_navbar_entries()
2137                    .map(|(_, entry)| EntryMinimal {
2138                        is_root: entry.is_root,
2139                        title: entry.title,
2140                    })
2141                    .collect::<Vec<_>>(),
2142                self.visible_navbar_entries()
2143                    .map(|(_, entry)| EntryMinimal {
2144                        is_root: entry.is_root,
2145                        title: entry.title,
2146                    })
2147                    .collect::<Vec<_>>(),
2148            );
2149            assert_eq!(
2150                self.current_page().items.iter().collect::<Vec<_>>(),
2151                other.page_items().collect::<Vec<_>>()
2152            );
2153        }
2154    }
2155
2156    impl SettingsPage {
2157        fn item(mut self, item: SettingsPageItem) -> Self {
2158            self.items.push(item);
2159            self
2160        }
2161    }
2162
2163    impl SettingsPageItem {
2164        fn basic_item(title: &'static str, description: &'static str) -> Self {
2165            SettingsPageItem::SettingItem(SettingItem {
2166                files: USER,
2167                title,
2168                description,
2169                field: Box::new(SettingField {
2170                    pick: |settings_content| &settings_content.auto_update,
2171                    pick_mut: |settings_content| &mut settings_content.auto_update,
2172                }),
2173                metadata: None,
2174            })
2175        }
2176    }
2177
2178    fn register_settings(cx: &mut App) {
2179        settings::init(cx);
2180        theme::init(theme::LoadThemes::JustBase, cx);
2181        workspace::init_settings(cx);
2182        project::Project::init_settings(cx);
2183        language::init(cx);
2184        editor::init(cx);
2185        menu::init();
2186    }
2187
2188    fn parse(input: &'static str, window: &mut Window, cx: &mut App) -> SettingsWindow {
2189        let mut pages: Vec<SettingsPage> = Vec::new();
2190        let mut expanded_pages = Vec::new();
2191        let mut selected_idx = None;
2192        let mut index = 0;
2193        let mut in_expanded_section = false;
2194
2195        for mut line in input
2196            .lines()
2197            .map(|line| line.trim())
2198            .filter(|line| !line.is_empty())
2199        {
2200            if let Some(pre) = line.strip_suffix('*') {
2201                assert!(selected_idx.is_none(), "Only one selected entry allowed");
2202                selected_idx = Some(index);
2203                line = pre;
2204            }
2205            let (kind, title) = line.split_once(" ").unwrap();
2206            assert_eq!(kind.len(), 1);
2207            let kind = kind.chars().next().unwrap();
2208            if kind == 'v' {
2209                let page_idx = pages.len();
2210                expanded_pages.push(page_idx);
2211                pages.push(SettingsPage {
2212                    title,
2213                    items: vec![],
2214                });
2215                index += 1;
2216                in_expanded_section = true;
2217            } else if kind == '>' {
2218                pages.push(SettingsPage {
2219                    title,
2220                    items: vec![],
2221                });
2222                index += 1;
2223                in_expanded_section = false;
2224            } else if kind == '-' {
2225                pages
2226                    .last_mut()
2227                    .unwrap()
2228                    .items
2229                    .push(SettingsPageItem::SectionHeader(title));
2230                if selected_idx == Some(index) && !in_expanded_section {
2231                    panic!("Items in unexpanded sections cannot be selected");
2232                }
2233                index += 1;
2234            } else {
2235                panic!(
2236                    "Entries must start with one of 'v', '>', or '-'\n line: {}",
2237                    line
2238                );
2239            }
2240        }
2241
2242        let mut settings_window = SettingsWindow {
2243            original_window: None,
2244            worktree_root_dirs: HashMap::default(),
2245            files: Vec::default(),
2246            current_file: crate::SettingsUiFile::User,
2247            pages,
2248            search_bar: cx.new(|cx| Editor::single_line(window, cx)),
2249            navbar_entry: selected_idx.expect("Must have a selected navbar entry"),
2250            navbar_entries: Vec::default(),
2251            list_handle: UniformListScrollHandle::default(),
2252            search_matches: vec![],
2253            search_task: None,
2254            scroll_handle: ScrollHandle::new(),
2255            focus_handle: cx.focus_handle(),
2256            navbar_focus_handle: cx.focus_handle(),
2257            content_focus_handle: cx.focus_handle(),
2258            files_focus_handle: cx.focus_handle(),
2259        };
2260
2261        settings_window.build_search_matches();
2262        settings_window.build_navbar();
2263        for expanded_page_index in expanded_pages {
2264            for entry in &mut settings_window.navbar_entries {
2265                if entry.page_index == expanded_page_index && entry.is_root {
2266                    entry.expanded = true;
2267                }
2268            }
2269        }
2270        settings_window
2271    }
2272
2273    #[track_caller]
2274    fn check_navbar_toggle(
2275        before: &'static str,
2276        toggle_page: &'static str,
2277        after: &'static str,
2278        window: &mut Window,
2279        cx: &mut App,
2280    ) {
2281        let mut settings_window = parse(before, window, cx);
2282        let toggle_page_idx = settings_window
2283            .pages
2284            .iter()
2285            .position(|page| page.title == toggle_page)
2286            .expect("page not found");
2287        let toggle_idx = settings_window
2288            .navbar_entries
2289            .iter()
2290            .position(|entry| entry.page_index == toggle_page_idx)
2291            .expect("page not found");
2292        settings_window.toggle_navbar_entry(toggle_idx);
2293
2294        let expected_settings_window = parse(after, window, cx);
2295
2296        pretty_assertions::assert_eq!(
2297            settings_window
2298                .visible_navbar_entries()
2299                .map(|(_, entry)| entry)
2300                .collect::<Vec<_>>(),
2301            expected_settings_window
2302                .visible_navbar_entries()
2303                .map(|(_, entry)| entry)
2304                .collect::<Vec<_>>(),
2305        );
2306        pretty_assertions::assert_eq!(
2307            settings_window.navbar_entries[settings_window.navbar_entry()],
2308            expected_settings_window.navbar_entries[expected_settings_window.navbar_entry()],
2309        );
2310    }
2311
2312    macro_rules! check_navbar_toggle {
2313        ($name:ident, before: $before:expr, toggle_page: $toggle_page:expr, after: $after:expr) => {
2314            #[gpui::test]
2315            fn $name(cx: &mut gpui::TestAppContext) {
2316                let window = cx.add_empty_window();
2317                window.update(|window, cx| {
2318                    register_settings(cx);
2319                    check_navbar_toggle($before, $toggle_page, $after, window, cx);
2320                });
2321            }
2322        };
2323    }
2324
2325    check_navbar_toggle!(
2326        navbar_basic_open,
2327        before: r"
2328        v General
2329        - General
2330        - Privacy*
2331        v Project
2332        - Project Settings
2333        ",
2334        toggle_page: "General",
2335        after: r"
2336        > General*
2337        v Project
2338        - Project Settings
2339        "
2340    );
2341
2342    check_navbar_toggle!(
2343        navbar_basic_close,
2344        before: r"
2345        > General*
2346        - General
2347        - Privacy
2348        v Project
2349        - Project Settings
2350        ",
2351        toggle_page: "General",
2352        after: r"
2353        v General*
2354        - General
2355        - Privacy
2356        v Project
2357        - Project Settings
2358        "
2359    );
2360
2361    check_navbar_toggle!(
2362        navbar_basic_second_root_entry_close,
2363        before: r"
2364        > General
2365        - General
2366        - Privacy
2367        v Project
2368        - Project Settings*
2369        ",
2370        toggle_page: "Project",
2371        after: r"
2372        > General
2373        > Project*
2374        "
2375    );
2376
2377    check_navbar_toggle!(
2378        navbar_toggle_subroot,
2379        before: r"
2380        v General Page
2381        - General
2382        - Privacy
2383        v Project
2384        - Worktree Settings Content*
2385        v AI
2386        - General
2387        > Appearance & Behavior
2388        ",
2389        toggle_page: "Project",
2390        after: r"
2391        v General Page
2392        - General
2393        - Privacy
2394        > Project*
2395        v AI
2396        - General
2397        > Appearance & Behavior
2398        "
2399    );
2400
2401    check_navbar_toggle!(
2402        navbar_toggle_close_propagates_selected_index,
2403        before: r"
2404        v General Page
2405        - General
2406        - Privacy
2407        v Project
2408        - Worktree Settings Content
2409        v AI
2410        - General*
2411        > Appearance & Behavior
2412        ",
2413        toggle_page: "General Page",
2414        after: r"
2415        > General Page
2416        v Project
2417        - Worktree Settings Content
2418        v AI
2419        - General*
2420        > Appearance & Behavior
2421        "
2422    );
2423
2424    check_navbar_toggle!(
2425        navbar_toggle_expand_propagates_selected_index,
2426        before: r"
2427        > General Page
2428        - General
2429        - Privacy
2430        v Project
2431        - Worktree Settings Content
2432        v AI
2433        - General*
2434        > Appearance & Behavior
2435        ",
2436        toggle_page: "General Page",
2437        after: r"
2438        v General Page
2439        - General
2440        - Privacy
2441        v Project
2442        - Worktree Settings Content
2443        v AI
2444        - General*
2445        > Appearance & Behavior
2446        "
2447    );
2448
2449    #[gpui::test]
2450    fn test_basic_search(cx: &mut gpui::TestAppContext) {
2451        let cx = cx.add_empty_window();
2452        let (actual, expected) = cx.update(|window, cx| {
2453            register_settings(cx);
2454
2455            let expected = cx.new(|cx| {
2456                SettingsWindow::new_builder(window, cx)
2457                    .add_page("General", |page| {
2458                        page.item(SettingsPageItem::SectionHeader("General settings"))
2459                            .item(SettingsPageItem::basic_item("test title", "General test"))
2460                    })
2461                    .build()
2462            });
2463
2464            let actual = cx.new(|cx| {
2465                SettingsWindow::new_builder(window, cx)
2466                    .add_page("General", |page| {
2467                        page.item(SettingsPageItem::SectionHeader("General settings"))
2468                            .item(SettingsPageItem::basic_item("test title", "General test"))
2469                    })
2470                    .add_page("Theme", |page| {
2471                        page.item(SettingsPageItem::SectionHeader("Theme settings"))
2472                    })
2473                    .build()
2474            });
2475
2476            actual.update(cx, |settings, cx| settings.search("gen", window, cx));
2477
2478            (actual, expected)
2479        });
2480
2481        cx.cx.run_until_parked();
2482
2483        cx.update(|_window, cx| {
2484            let expected = expected.read(cx);
2485            let actual = actual.read(cx);
2486            expected.assert_search_results(&actual);
2487        })
2488    }
2489
2490    #[gpui::test]
2491    fn test_search_render_page_with_filtered_out_navbar_entries(cx: &mut gpui::TestAppContext) {
2492        let cx = cx.add_empty_window();
2493        let (actual, expected) = cx.update(|window, cx| {
2494            register_settings(cx);
2495
2496            let actual = cx.new(|cx| {
2497                SettingsWindow::new_builder(window, cx)
2498                    .add_page("General", |page| {
2499                        page.item(SettingsPageItem::SectionHeader("General settings"))
2500                            .item(SettingsPageItem::basic_item(
2501                                "Confirm Quit",
2502                                "Whether to confirm before quitting Zed",
2503                            ))
2504                            .item(SettingsPageItem::basic_item(
2505                                "Auto Update",
2506                                "Automatically update Zed",
2507                            ))
2508                    })
2509                    .add_page("AI", |page| {
2510                        page.item(SettingsPageItem::basic_item(
2511                            "Disable AI",
2512                            "Whether to disable all AI features in Zed",
2513                        ))
2514                    })
2515                    .add_page("Appearance & Behavior", |page| {
2516                        page.item(SettingsPageItem::SectionHeader("Cursor")).item(
2517                            SettingsPageItem::basic_item(
2518                                "Cursor Shape",
2519                                "Cursor shape for the editor",
2520                            ),
2521                        )
2522                    })
2523                    .build()
2524            });
2525
2526            let expected = cx.new(|cx| {
2527                SettingsWindow::new_builder(window, cx)
2528                    .add_page("Appearance & Behavior", |page| {
2529                        page.item(SettingsPageItem::SectionHeader("Cursor")).item(
2530                            SettingsPageItem::basic_item(
2531                                "Cursor Shape",
2532                                "Cursor shape for the editor",
2533                            ),
2534                        )
2535                    })
2536                    .build()
2537            });
2538
2539            actual.update(cx, |settings, cx| settings.search("cursor", window, cx));
2540
2541            (actual, expected)
2542        });
2543
2544        cx.cx.run_until_parked();
2545
2546        cx.update(|_window, cx| {
2547            let expected = expected.read(cx);
2548            let actual = actual.read(cx);
2549            expected.assert_search_results(&actual);
2550        })
2551    }
2552}