From daebc4052dbcc19a3af474677b05680249935557 Mon Sep 17 00:00:00 2001 From: Anthony Eid <56899983+Anthony-Eid@users.noreply.github.com> Date: Thu, 25 Sep 2025 17:19:18 -0400 Subject: [PATCH] settings ui: Implement dynamic navbar based on pages section headers (#38915) Release Notes: - N/A --------- Co-authored-by: Ben Kunkle --- crates/settings_ui/src/settings_ui.rs | 386 +++++++++++++++++++++++--- 1 file changed, 345 insertions(+), 41 deletions(-) diff --git a/crates/settings_ui/src/settings_ui.rs b/crates/settings_ui/src/settings_ui.rs index e3d77bcf288dcbb95642119dc46a2c6b1602fa52..71979eb9d946a3b6e9494ac92dcf3046b0ed8816 100644 --- a/crates/settings_ui/src/settings_ui.rs +++ b/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 { vec![ SettingsPage { title: "General Page", + expanded: true, items: vec![ SettingsPageItem::SectionHeader("General"), SettingsPageItem::SettingItem(SettingItem { @@ -41,10 +43,12 @@ fn user_settings_data() -> Vec { }) }, }), + 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 { title: "AI", + expanded: true, items: vec![ SettingsPageItem::SectionHeader("General"), SettingsPageItem::SettingItem(SettingItem { @@ -75,6 +80,7 @@ fn user_settings_data() -> Vec { }, SettingsPage { title: "Appearance & Behavior", + expanded: true, items: vec![ SettingsPageItem::SectionHeader("Cursor"), SettingsPageItem::SettingItem(SettingItem { @@ -98,6 +104,7 @@ fn user_settings_data() -> Vec { fn project_settings_data() -> Vec { 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, search: Entity, - current_page: usize, // Index into pages - should probably be (usize, Option) for section + page + navbar_entry: usize, // Index into pages - should probably be (usize, Option) for section + page + navbar_entries: Vec, + 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, } +impl SettingsPage { + fn section_headers(&self) -> impl Iterator { + 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::(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) { 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) { let settings_store = cx.global::(); 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) { 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) -> 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) -> 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, _, 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::() + - 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 = 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* + " + ); +}