settings ui: Implement dynamic navbar based on pages section headers (#38915)

Anthony Eid and Ben Kunkle created

Release Notes:

- N/A

---------

Co-authored-by: Ben Kunkle <ben@zed.dev>

Change summary

crates/settings_ui/src/settings_ui.rs | 386 +++++++++++++++++++++++++---
1 file changed, 345 insertions(+), 41 deletions(-)

Detailed changes

crates/settings_ui/src/settings_ui.rs 🔗

@@ -1,19 +1,20 @@
 //! # settings_ui
-use std::sync::Arc;
+use std::{ops::Range, sync::Arc};
 
 use editor::Editor;
 use feature_flags::{FeatureFlag, FeatureFlagAppExt as _};
 use gpui::{
-    App, AppContext as _, Context, Div, Entity, IntoElement, ReadGlobal as _, Render, Window,
-    WindowHandle, WindowOptions, actions, div, px, size,
+    App, AppContext as _, Context, Div, Entity, IntoElement, ReadGlobal as _, Render,
+    UniformListScrollHandle, Window, WindowHandle, WindowOptions, actions, div, px, size,
+    uniform_list,
 };
 use project::WorktreeId;
 use settings::{SettingsContent, SettingsStore};
 use ui::{
     ActiveTheme as _, AnyElement, BorrowAppContext as _, Button, Clickable as _, Color, Divider,
     DropdownMenu, FluentBuilder as _, Icon, IconName, InteractiveElement as _, Label,
-    LabelCommon as _, LabelSize, ParentElement, SharedString, StatefulInteractiveElement as _,
-    Styled, Switch, h_flex, v_flex,
+    LabelCommon as _, LabelSize, ListItem, ParentElement, SharedString,
+    StatefulInteractiveElement as _, Styled, StyledTypography, Switch, h_flex, v_flex,
 };
 use util::{paths::PathStyle, rel_path::RelPath};
 
@@ -21,6 +22,7 @@ fn user_settings_data() -> Vec<SettingsPage> {
     vec![
         SettingsPage {
             title: "General Page",
+            expanded: true,
             items: vec![
                 SettingsPageItem::SectionHeader("General"),
                 SettingsPageItem::SettingItem(SettingItem {
@@ -41,10 +43,12 @@ fn user_settings_data() -> Vec<SettingsPage> {
                         })
                     },
                 }),
+                SettingsPageItem::SectionHeader("Privacy"),
             ],
         },
         SettingsPage {
             title: "Project",
+            expanded: true,
             items: vec![
                 SettingsPageItem::SectionHeader("Worktree Settings Content"),
                 SettingsPageItem::SettingItem(SettingItem {
@@ -60,6 +64,7 @@ fn user_settings_data() -> Vec<SettingsPage> {
         },
         SettingsPage {
             title: "AI",
+            expanded: true,
             items: vec![
                 SettingsPageItem::SectionHeader("General"),
                 SettingsPageItem::SettingItem(SettingItem {
@@ -75,6 +80,7 @@ fn user_settings_data() -> Vec<SettingsPage> {
         },
         SettingsPage {
             title: "Appearance & Behavior",
+            expanded: true,
             items: vec![
                 SettingsPageItem::SectionHeader("Cursor"),
                 SettingsPageItem::SettingItem(SettingItem {
@@ -98,6 +104,7 @@ fn user_settings_data() -> Vec<SettingsPage> {
 fn project_settings_data() -> Vec<SettingsPage> {
     vec![SettingsPage {
         title: "Project",
+        expanded: true,
         items: vec![
             SettingsPageItem::SectionHeader("Worktree Settings Content"),
             SettingsPageItem::SettingItem(SettingItem {
@@ -170,15 +177,33 @@ pub struct SettingsWindow {
     current_file: SettingsFile,
     pages: Vec<SettingsPage>,
     search: Entity<Editor>,
-    current_page: usize, // Index into pages - should probably be (usize, Option<usize>) for section + page
+    navbar_entry: usize, // Index into pages - should probably be (usize, Option<usize>) for section + page
+    navbar_entries: Vec<NavBarEntry>,
+    list_handle: UniformListScrollHandle,
+}
+
+#[derive(PartialEq, Debug)]
+struct NavBarEntry {
+    title: &'static str,
+    is_root: bool,
 }
 
 #[derive(Clone)]
 struct SettingsPage {
     title: &'static str,
+    expanded: bool,
     items: Vec<SettingsPageItem>,
 }
 
+impl SettingsPage {
+    fn section_headers(&self) -> impl Iterator<Item = &'static str> {
+        self.items.iter().filter_map(|item| match item {
+            SettingsPageItem::SectionHeader(header) => Some(*header),
+            _ => None,
+        })
+    }
+}
+
 #[derive(Clone)]
 enum SettingsPageItem {
     SectionHeader(&'static str),
@@ -274,7 +299,9 @@ impl SettingsWindow {
             files: vec![],
             current_file: current_file,
             pages: vec![],
-            current_page: 0,
+            navbar_entries: vec![],
+            navbar_entry: 0,
+            list_handle: UniformListScrollHandle::default(),
             search,
         };
         cx.observe_global_in::<SettingsStore>(window, move |this, _, cx| {
@@ -284,15 +311,56 @@ impl SettingsWindow {
         .detach();
         this.fetch_files(cx);
 
-        this.build_ui();
+        this.build_ui(cx);
         this
     }
 
-    fn build_ui(&mut self) {
+    fn toggle_navbar_entry(&mut self, ix: usize) {
+        if self.navbar_entries[ix].is_root {
+            let expanded = &mut self.page_for_navbar_index(ix).expanded;
+            *expanded = !*expanded;
+            let current_page_index = self.page_index_from_navbar_index(self.navbar_entry);
+            // if currently selected page is a child of the parent page we are folding,
+            // set the current page to the parent page
+            if current_page_index == ix {
+                self.navbar_entry = ix;
+            }
+            self.build_navbar();
+        }
+    }
+
+    fn build_navbar(&mut self) {
+        self.navbar_entries = self
+            .pages
+            .iter()
+            .flat_map(|page| {
+                std::iter::once(NavBarEntry {
+                    title: page.title,
+                    is_root: true,
+                })
+                .chain(
+                    page.expanded
+                        .then(|| {
+                            page.section_headers().map(|h| NavBarEntry {
+                                title: h,
+                                is_root: false,
+                            })
+                        })
+                        .into_iter()
+                        .flatten(),
+                )
+            })
+            .collect();
+    }
+
+    fn build_ui(&mut self, cx: &mut Context<SettingsWindow>) {
         self.pages = self.current_file.pages();
+        self.build_navbar();
+
+        cx.notify();
     }
 
-    fn fetch_files(&mut self, cx: &mut App) {
+    fn fetch_files(&mut self, cx: &mut Context<SettingsWindow>) {
         let settings_store = cx.global::<SettingsStore>();
         let mut ui_files = vec![];
         let all_files = settings_store.get_all_files();
@@ -309,12 +377,12 @@ impl SettingsWindow {
         }
         ui_files.reverse();
         if !ui_files.contains(&self.current_file) {
-            self.change_file(0);
+            self.change_file(0, cx);
         }
         self.files = ui_files;
     }
 
-    fn change_file(&mut self, ix: usize) {
+    fn change_file(&mut self, ix: usize, cx: &mut Context<SettingsWindow>) {
         if ix >= self.files.len() {
             self.current_file = SettingsFile::User;
             return;
@@ -323,7 +391,7 @@ impl SettingsWindow {
             return;
         }
         self.current_file = self.files[ix].clone();
-        self.build_ui();
+        self.build_ui(cx);
     }
 
     fn render_files(&self, _window: &mut Window, cx: &mut Context<SettingsWindow>) -> Div {
@@ -333,42 +401,88 @@ impl SettingsWindow {
             .gap_1()
             .children(self.files.iter().enumerate().map(|(ix, file)| {
                 Button::new(ix, file.name())
-                    .on_click(cx.listener(move |this, _, _window, _cx| this.change_file(ix)))
+                    .on_click(cx.listener(move |this, _, _window, cx| this.change_file(ix, cx)))
             }))
     }
 
     fn render_search(&self, _window: &mut Window, _cx: &mut App) -> Div {
-        div()
+        h_flex()
             .child(Icon::new(IconName::MagnifyingGlass))
             .child(self.search.clone())
     }
 
     fn render_nav(&self, window: &mut Window, cx: &mut Context<SettingsWindow>) -> Div {
-        let mut nav = v_flex()
-            .p_4()
-            .gap_2()
+        v_flex()
             .bg(cx.theme().colors().panel_background)
+            .p_3()
             .child(div().h_10()) // Files spacer;
-            .child(self.render_search(window, cx));
-
-        for (ix, page) in self.pages.iter().enumerate() {
-            nav = nav.child(
-                div()
-                    .id(page.title)
-                    .child(
-                        Label::new(page.title)
-                            .size(LabelSize::Large)
-                            .when(self.is_page_selected(ix), |this| {
-                                this.color(Color::Selected)
-                            }),
-                    )
-                    .on_click(cx.listener(move |this, _, _, cx| {
-                        this.current_page = ix;
-                        cx.notify();
-                    })),
-            );
-        }
-        nav
+            .child(self.render_search(window, cx).pb_1())
+            .gap_3()
+            .child(
+                uniform_list(
+                    "settings-ui-nav-bar",
+                    self.navbar_entries.len(),
+                    cx.processor(|this, range: Range<usize>, _, cx| {
+                        range
+                            .into_iter()
+                            .map(|ix| {
+                                let entry = &this.navbar_entries[ix];
+
+                                div()
+                                    .id(("settings-ui-section", ix))
+                                    .child(
+                                        ListItem::new(("settings-ui-navbar-entry", ix))
+                                            .selectable(true)
+                                            .indent_step_size(px(10.))
+                                            .indent_level(if entry.is_root { 1 } else { 3 })
+                                            .when(entry.is_root, |item| {
+                                                item.toggle(
+                                                    this.pages
+                                                        [this.page_index_from_navbar_index(ix)]
+                                                    .expanded,
+                                                )
+                                                .always_show_disclosure_icon(true)
+                                                .on_toggle(cx.listener(move |this, _, _, cx| {
+                                                    this.toggle_navbar_entry(ix);
+                                                    cx.notify();
+                                                }))
+                                            })
+                                            .child(
+                                                div()
+                                                    .text_ui(cx)
+                                                    .size_full()
+                                                    .child(entry.title)
+                                                    .hover(|style| {
+                                                        style.bg(cx.theme().colors().element_hover)
+                                                    })
+                                                    .when(!entry.is_root, |this| {
+                                                        this.text_color(
+                                                            cx.theme().colors().text_muted,
+                                                        )
+                                                    })
+                                                    .when(
+                                                        this.is_navbar_entry_selected(ix),
+                                                        |this| {
+                                                            this.text_color(
+                                                                Color::Selected.color(cx),
+                                                            )
+                                                        },
+                                                    ),
+                                            ),
+                                    )
+                                    .on_click(cx.listener(move |this, _, _, cx| {
+                                        this.navbar_entry = ix;
+                                        cx.notify();
+                                    }))
+                            })
+                            .collect()
+                    }),
+                )
+                .track_scroll(self.list_handle.clone())
+                .gap_1_5()
+                .size_full()
+                .flex_grow(),
+            )
     }
 
     fn render_page(
@@ -385,11 +499,25 @@ impl SettingsWindow {
     }
 
     fn current_page(&self) -> &SettingsPage {
-        &self.pages[self.current_page]
+        &self.pages[self.page_index_from_navbar_index(self.navbar_entry)]
+    }
+
+    fn page_index_from_navbar_index(&self, index: usize) -> usize {
+        self.navbar_entries
+            .iter()
+            .take(index + 1)
+            .map(|entry| entry.is_root as usize)
+            .sum::<usize>()
+            - 1
     }
 
-    fn is_page_selected(&self, ix: usize) -> bool {
-        ix == self.current_page
+    fn page_for_navbar_index(&mut self, index: usize) -> &mut SettingsPage {
+        let index = self.page_index_from_navbar_index(index);
+        &mut self.pages[index]
+    }
+
+    fn is_navbar_entry_selected(&self, ix: usize) -> bool {
+        ix == self.navbar_entry
     }
 }
 
@@ -577,3 +705,179 @@ where
     )
     .into_any_element()
 }
+
+#[cfg(test)]
+mod test {
+    use super::*;
+
+    impl SettingsWindow {
+        fn navbar(&self) -> &[NavBarEntry] {
+            self.navbar_entries.as_slice()
+        }
+
+        fn navbar_entry(&self) -> usize {
+            self.navbar_entry
+        }
+    }
+
+    fn register_settings(cx: &mut App) {
+        settings::init(cx);
+        theme::init(theme::LoadThemes::JustBase, cx);
+        workspace::init_settings(cx);
+        project::Project::init_settings(cx);
+        language::init(cx);
+        editor::init(cx);
+        menu::init();
+    }
+
+    fn parse(input: &'static str, window: &mut Window, cx: &mut App) -> SettingsWindow {
+        let mut pages: Vec<SettingsPage> = Vec::new();
+        let mut current_page = None;
+        let mut selected_idx = None;
+
+        for (ix, mut line) in input
+            .lines()
+            .map(|line| line.trim())
+            .filter(|line| !line.is_empty())
+            .enumerate()
+        {
+            if line.ends_with("*") {
+                assert!(
+                    selected_idx.is_none(),
+                    "Can only have one selected navbar entry at a time"
+                );
+                selected_idx = Some(ix);
+                line = &line[..line.len() - 1];
+            }
+
+            if line.starts_with("v") || line.starts_with(">") {
+                if let Some(current_page) = current_page.take() {
+                    pages.push(current_page);
+                }
+
+                let expanded = line.starts_with("v");
+
+                current_page = Some(SettingsPage {
+                    title: line.split_once(" ").unwrap().1,
+                    expanded,
+                    items: Vec::default(),
+                });
+            } else if line.starts_with("-") {
+                let Some(current_page) = current_page.as_mut() else {
+                    panic!("Sub entries must be within a page");
+                };
+
+                current_page.items.push(SettingsPageItem::SectionHeader(
+                    line.split_once(" ").unwrap().1,
+                ));
+            } else {
+                panic!(
+                    "Entries must start with one of 'v', '>', or '-'\n line: {}",
+                    line
+                );
+            }
+        }
+
+        if let Some(current_page) = current_page.take() {
+            pages.push(current_page);
+        }
+
+        let mut settings_window = SettingsWindow {
+            files: Vec::default(),
+            current_file: crate::SettingsFile::User,
+            pages,
+            search: cx.new(|cx| Editor::single_line(window, cx)),
+            navbar_entry: selected_idx.unwrap(),
+            navbar_entries: Vec::default(),
+            list_handle: UniformListScrollHandle::default(),
+        };
+
+        settings_window.build_navbar();
+        settings_window
+    }
+
+    #[track_caller]
+    fn check_navbar_toggle(
+        before: &'static str,
+        toggle_idx: usize,
+        after: &'static str,
+        window: &mut Window,
+        cx: &mut App,
+    ) {
+        let mut settings_window = parse(before, window, cx);
+        settings_window.toggle_navbar_entry(toggle_idx);
+
+        let expected_settings_window = parse(after, window, cx);
+
+        assert_eq!(settings_window.navbar(), expected_settings_window.navbar());
+        assert_eq!(
+            settings_window.navbar_entry(),
+            expected_settings_window.navbar_entry()
+        );
+    }
+
+    macro_rules! check_navbar_toggle {
+        ($name:ident, before: $before:expr, toggle_idx: $toggle_idx:expr, after: $after:expr) => {
+            #[gpui::test]
+            fn $name(cx: &mut gpui::TestAppContext) {
+                let window = cx.add_empty_window();
+                window.update(|window, cx| {
+                    register_settings(cx);
+                    check_navbar_toggle($before, $toggle_idx, $after, window, cx);
+                });
+            }
+        };
+    }
+
+    check_navbar_toggle!(
+        basic_open,
+        before: r"
+        v General
+        - General
+        - Privacy*
+        v Project
+        - Project Settings
+        ",
+        toggle_idx: 0,
+        after: r"
+        > General*
+        v Project
+        - Project Settings
+        "
+    );
+
+    check_navbar_toggle!(
+        basic_close,
+        before: r"
+        > General*
+        - General
+        - Privacy
+        v Project
+        - Project Settings
+        ",
+        toggle_idx: 0,
+        after: r"
+        v General*
+        - General
+        - Privacy
+        v Project
+        - Project Settings
+        "
+    );
+
+    check_navbar_toggle!(
+        basic_second_root_entry_close,
+        before: r"
+        > General
+        - General
+        - Privacy
+        v Project
+        - Project Settings*
+        ",
+        toggle_idx: 1,
+        after: r"
+        > General
+        > Project*
+        "
+    );
+}