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

Release Notes:

- N/A

---------

Co-authored-by: Ben Kunkle <ben@zed.dev>
This commit is contained in:
Anthony Eid
2025-09-25 21:19:18 +00:00
committed by GitHub
co-authored by Ben Kunkle
parent ecc35fcd9a
commit daebc4052d
+345 -41
View File
@@ -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) {
self.pages = self.current_file.pages();
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 fetch_files(&mut self, cx: &mut App) {
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 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));
.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];
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
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 is_page_selected(&self, ix: usize) -> bool {
ix == self.current_page
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 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*
"
);
}