settings_ui.rs

  1//! # settings_ui
  2use std::{ops::Range, sync::Arc};
  3
  4use editor::Editor;
  5use feature_flags::{FeatureFlag, FeatureFlagAppExt as _};
  6use gpui::{
  7    App, AppContext as _, Context, Div, Entity, IntoElement, ReadGlobal as _, Render,
  8    UniformListScrollHandle, Window, WindowHandle, WindowOptions, actions, div, px, size,
  9    uniform_list,
 10};
 11use project::WorktreeId;
 12use settings::{SettingsContent, SettingsStore};
 13use ui::{
 14    ActiveTheme as _, AnyElement, BorrowAppContext as _, Button, Clickable as _, Color, Divider,
 15    DropdownMenu, FluentBuilder as _, Icon, IconName, InteractiveElement as _, Label,
 16    LabelCommon as _, LabelSize, ListItem, ParentElement, SharedString,
 17    StatefulInteractiveElement as _, Styled, StyledTypography, Switch, h_flex, v_flex,
 18};
 19use util::{paths::PathStyle, rel_path::RelPath};
 20
 21fn user_settings_data() -> Vec<SettingsPage> {
 22    vec![
 23        SettingsPage {
 24            title: "General Page",
 25            expanded: true,
 26            items: vec![
 27                SettingsPageItem::SectionHeader("General"),
 28                SettingsPageItem::SettingItem(SettingItem {
 29                    title: "Confirm Quit",
 30                    description: "Whether to confirm before quitting Zed",
 31                    render: |file, _, cx| {
 32                        render_toggle_button("confirm_quit", file, cx, |settings_content| {
 33                            &mut settings_content.workspace.confirm_quit
 34                        })
 35                    },
 36                }),
 37                SettingsPageItem::SettingItem(SettingItem {
 38                    title: "Auto Update",
 39                    description: "Automatically update Zed (may be ignored on Linux if installed through a package manager)",
 40                    render: |file, _, cx| {
 41                        render_toggle_button("Auto Update", file, cx, |settings_content| {
 42                            &mut settings_content.auto_update
 43                        })
 44                    },
 45                }),
 46                SettingsPageItem::SectionHeader("Privacy"),
 47            ],
 48        },
 49        SettingsPage {
 50            title: "Project",
 51            expanded: true,
 52            items: vec![
 53                SettingsPageItem::SectionHeader("Worktree Settings Content"),
 54                SettingsPageItem::SettingItem(SettingItem {
 55                    title: "Project Name",
 56                    description: "The displayed name of this project. If not set, the root directory name",
 57                    render: |file, window, cx| {
 58                        render_text_field("project_name", file, window, cx, |settings_content| {
 59                            &mut settings_content.project.worktree.project_name
 60                        })
 61                    },
 62                }),
 63            ],
 64        },
 65        SettingsPage {
 66            title: "AI",
 67            expanded: true,
 68            items: vec![
 69                SettingsPageItem::SectionHeader("General"),
 70                SettingsPageItem::SettingItem(SettingItem {
 71                    title: "Disable AI",
 72                    description: "Whether to disable all AI features in Zed",
 73                    render: |file, _, cx| {
 74                        render_toggle_button("disable_AI", file, cx, |settings_content| {
 75                            &mut settings_content.disable_ai
 76                        })
 77                    },
 78                }),
 79            ],
 80        },
 81        SettingsPage {
 82            title: "Appearance & Behavior",
 83            expanded: true,
 84            items: vec![
 85                SettingsPageItem::SectionHeader("Cursor"),
 86                SettingsPageItem::SettingItem(SettingItem {
 87                    title: "Cursor Shape",
 88                    description: "Cursor shape for the editor",
 89                    render: |file, window, cx| {
 90                        render_dropdown::<settings::CursorShape>(
 91                            "cursor_shape",
 92                            file,
 93                            window,
 94                            cx,
 95                            |settings_content| &mut settings_content.editor.cursor_shape,
 96                        )
 97                    },
 98                }),
 99            ],
100        },
101    ]
102}
103
104fn project_settings_data() -> Vec<SettingsPage> {
105    vec![SettingsPage {
106        title: "Project",
107        expanded: true,
108        items: vec![
109            SettingsPageItem::SectionHeader("Worktree Settings Content"),
110            SettingsPageItem::SettingItem(SettingItem {
111                title: "Project Name",
112                description: " The displayed name of this project. If not set, the root directory name",
113                render: |file, window, cx| {
114                    render_text_field("project_name", file, window, cx, |settings_content| {
115                        &mut settings_content.project.worktree.project_name
116                    })
117                },
118            }),
119        ],
120    }]
121}
122
123pub struct SettingsUiFeatureFlag;
124
125impl FeatureFlag for SettingsUiFeatureFlag {
126    const NAME: &'static str = "settings-ui";
127}
128
129actions!(
130    zed,
131    [
132        /// Opens Settings Editor.
133        OpenSettingsEditor
134    ]
135);
136
137pub fn init(cx: &mut App) {
138    cx.observe_new(|workspace: &mut workspace::Workspace, _, _| {
139        workspace.register_action_renderer(|div, _, _, cx| {
140            let settings_ui_actions = [std::any::TypeId::of::<OpenSettingsEditor>()];
141            let has_flag = cx.has_flag::<SettingsUiFeatureFlag>();
142            command_palette_hooks::CommandPaletteFilter::update_global(cx, |filter, _| {
143                if has_flag {
144                    filter.show_action_types(&settings_ui_actions);
145                } else {
146                    filter.hide_action_types(&settings_ui_actions);
147                }
148            });
149            if has_flag {
150                div.on_action(cx.listener(|_, _: &OpenSettingsEditor, _, cx| {
151                    open_settings_editor(cx).ok();
152                }))
153            } else {
154                div
155            }
156        });
157    })
158    .detach();
159}
160
161pub fn open_settings_editor(cx: &mut App) -> anyhow::Result<WindowHandle<SettingsWindow>> {
162    cx.open_window(
163        WindowOptions {
164            titlebar: None,
165            focus: true,
166            show: true,
167            kind: gpui::WindowKind::Normal,
168            window_min_size: Some(size(px(300.), px(500.))), // todo(settings_ui): Does this min_size make sense?
169            ..Default::default()
170        },
171        |window, cx| cx.new(|cx| SettingsWindow::new(window, cx)),
172    )
173}
174
175pub struct SettingsWindow {
176    files: Vec<SettingsFile>,
177    current_file: SettingsFile,
178    pages: Vec<SettingsPage>,
179    search: Entity<Editor>,
180    navbar_entry: usize, // Index into pages - should probably be (usize, Option<usize>) for section + page
181    navbar_entries: Vec<NavBarEntry>,
182    list_handle: UniformListScrollHandle,
183}
184
185#[derive(PartialEq, Debug)]
186struct NavBarEntry {
187    title: &'static str,
188    is_root: bool,
189}
190
191#[derive(Clone)]
192struct SettingsPage {
193    title: &'static str,
194    expanded: bool,
195    items: Vec<SettingsPageItem>,
196}
197
198impl SettingsPage {
199    fn section_headers(&self) -> impl Iterator<Item = &'static str> {
200        self.items.iter().filter_map(|item| match item {
201            SettingsPageItem::SectionHeader(header) => Some(*header),
202            _ => None,
203        })
204    }
205}
206
207#[derive(Clone)]
208enum SettingsPageItem {
209    SectionHeader(&'static str),
210    SettingItem(SettingItem),
211}
212
213impl SettingsPageItem {
214    fn render(&self, file: SettingsFile, window: &mut Window, cx: &mut App) -> AnyElement {
215        match self {
216            SettingsPageItem::SectionHeader(header) => div()
217                .w_full()
218                .child(Label::new(SharedString::new_static(header)).size(LabelSize::Large))
219                .child(Divider::horizontal().color(ui::DividerColor::BorderVariant))
220                .into_any_element(),
221            SettingsPageItem::SettingItem(setting_item) => div()
222                .child(
223                    Label::new(SharedString::new_static(setting_item.title))
224                        .size(LabelSize::Default),
225                )
226                .child(
227                    h_flex()
228                        .justify_between()
229                        .child(
230                            div()
231                                .child(
232                                    Label::new(SharedString::new_static(setting_item.description))
233                                        .size(LabelSize::Small)
234                                        .color(Color::Muted),
235                                )
236                                .max_w_1_2(),
237                        )
238                        .child((setting_item.render)(file, window, cx)),
239                )
240                .into_any_element(),
241        }
242    }
243}
244
245impl SettingsPageItem {
246    fn _header(&self) -> Option<&'static str> {
247        match self {
248            SettingsPageItem::SectionHeader(header) => Some(header),
249            _ => None,
250        }
251    }
252}
253
254#[derive(Clone)]
255struct SettingItem {
256    title: &'static str,
257    description: &'static str,
258    render: fn(file: SettingsFile, &mut Window, &mut App) -> AnyElement,
259}
260
261#[allow(unused)]
262#[derive(Clone, PartialEq)]
263enum SettingsFile {
264    User,                              // Uses all settings.
265    Local((WorktreeId, Arc<RelPath>)), // Has a special name, and special set of settings
266    Server(&'static str),              // Uses a special name, and the user settings
267}
268
269impl SettingsFile {
270    fn pages(&self) -> Vec<SettingsPage> {
271        match self {
272            SettingsFile::User => user_settings_data(),
273            SettingsFile::Local(_) => project_settings_data(),
274            SettingsFile::Server(_) => user_settings_data(),
275        }
276    }
277
278    fn name(&self) -> SharedString {
279        match self {
280            SettingsFile::User => SharedString::new_static("User"),
281            // TODO is PathStyle::local() ever not appropriate?
282            SettingsFile::Local((_, path)) => {
283                format!("Local ({})", path.display(PathStyle::local())).into()
284            }
285            SettingsFile::Server(file) => format!("Server ({})", file).into(),
286        }
287    }
288}
289
290impl SettingsWindow {
291    pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
292        let current_file = SettingsFile::User;
293        let search = cx.new(|cx| {
294            let mut editor = Editor::single_line(window, cx);
295            editor.set_placeholder_text("Search Settings", window, cx);
296            editor
297        });
298        let mut this = Self {
299            files: vec![],
300            current_file: current_file,
301            pages: vec![],
302            navbar_entries: vec![],
303            navbar_entry: 0,
304            list_handle: UniformListScrollHandle::default(),
305            search,
306        };
307        cx.observe_global_in::<SettingsStore>(window, move |this, _, cx| {
308            this.fetch_files(cx);
309            cx.notify();
310        })
311        .detach();
312        this.fetch_files(cx);
313
314        this.build_ui(cx);
315        this
316    }
317
318    fn toggle_navbar_entry(&mut self, ix: usize) {
319        if self.navbar_entries[ix].is_root {
320            let expanded = &mut self.page_for_navbar_index(ix).expanded;
321            *expanded = !*expanded;
322            let current_page_index = self.page_index_from_navbar_index(self.navbar_entry);
323            // if currently selected page is a child of the parent page we are folding,
324            // set the current page to the parent page
325            if current_page_index == ix {
326                self.navbar_entry = ix;
327            }
328            self.build_navbar();
329        }
330    }
331
332    fn build_navbar(&mut self) {
333        self.navbar_entries = self
334            .pages
335            .iter()
336            .flat_map(|page| {
337                std::iter::once(NavBarEntry {
338                    title: page.title,
339                    is_root: true,
340                })
341                .chain(
342                    page.expanded
343                        .then(|| {
344                            page.section_headers().map(|h| NavBarEntry {
345                                title: h,
346                                is_root: false,
347                            })
348                        })
349                        .into_iter()
350                        .flatten(),
351                )
352            })
353            .collect();
354    }
355
356    fn build_ui(&mut self, cx: &mut Context<SettingsWindow>) {
357        self.pages = self.current_file.pages();
358        self.build_navbar();
359
360        cx.notify();
361    }
362
363    fn fetch_files(&mut self, cx: &mut Context<SettingsWindow>) {
364        let settings_store = cx.global::<SettingsStore>();
365        let mut ui_files = vec![];
366        let all_files = settings_store.get_all_files();
367        for file in all_files {
368            let settings_ui_file = match file {
369                settings::SettingsFile::User => SettingsFile::User,
370                settings::SettingsFile::Global => continue,
371                settings::SettingsFile::Extension => continue,
372                settings::SettingsFile::Server => SettingsFile::Server("todo: server name"),
373                settings::SettingsFile::Default => continue,
374                settings::SettingsFile::Local(location) => SettingsFile::Local(location),
375            };
376            ui_files.push(settings_ui_file);
377        }
378        ui_files.reverse();
379        if !ui_files.contains(&self.current_file) {
380            self.change_file(0, cx);
381        }
382        self.files = ui_files;
383    }
384
385    fn change_file(&mut self, ix: usize, cx: &mut Context<SettingsWindow>) {
386        if ix >= self.files.len() {
387            self.current_file = SettingsFile::User;
388            return;
389        }
390        if self.files[ix] == self.current_file {
391            return;
392        }
393        self.current_file = self.files[ix].clone();
394        self.build_ui(cx);
395    }
396
397    fn render_files(&self, _window: &mut Window, cx: &mut Context<SettingsWindow>) -> Div {
398        div()
399            .flex()
400            .flex_row()
401            .gap_1()
402            .children(self.files.iter().enumerate().map(|(ix, file)| {
403                Button::new(ix, file.name())
404                    .on_click(cx.listener(move |this, _, _window, cx| this.change_file(ix, cx)))
405            }))
406    }
407
408    fn render_search(&self, _window: &mut Window, _cx: &mut App) -> Div {
409        h_flex()
410            .child(Icon::new(IconName::MagnifyingGlass))
411            .child(self.search.clone())
412    }
413
414    fn render_nav(&self, window: &mut Window, cx: &mut Context<SettingsWindow>) -> Div {
415        v_flex()
416            .bg(cx.theme().colors().panel_background)
417            .p_3()
418            .child(div().h_10()) // Files spacer;
419            .child(self.render_search(window, cx).pb_1())
420            .gap_3()
421            .child(
422                uniform_list(
423                    "settings-ui-nav-bar",
424                    self.navbar_entries.len(),
425                    cx.processor(|this, range: Range<usize>, _, cx| {
426                        range
427                            .into_iter()
428                            .map(|ix| {
429                                let entry = &this.navbar_entries[ix];
430
431                                div()
432                                    .id(("settings-ui-section", ix))
433                                    .child(
434                                        ListItem::new(("settings-ui-navbar-entry", ix))
435                                            .selectable(true)
436                                            .indent_step_size(px(10.))
437                                            .indent_level(if entry.is_root { 1 } else { 3 })
438                                            .when(entry.is_root, |item| {
439                                                item.toggle(
440                                                    this.pages
441                                                        [this.page_index_from_navbar_index(ix)]
442                                                    .expanded,
443                                                )
444                                                .always_show_disclosure_icon(true)
445                                                .on_toggle(cx.listener(move |this, _, _, cx| {
446                                                    this.toggle_navbar_entry(ix);
447                                                    cx.notify();
448                                                }))
449                                            })
450                                            .child(
451                                                div()
452                                                    .text_ui(cx)
453                                                    .size_full()
454                                                    .child(entry.title)
455                                                    .hover(|style| {
456                                                        style.bg(cx.theme().colors().element_hover)
457                                                    })
458                                                    .when(!entry.is_root, |this| {
459                                                        this.text_color(
460                                                            cx.theme().colors().text_muted,
461                                                        )
462                                                    })
463                                                    .when(
464                                                        this.is_navbar_entry_selected(ix),
465                                                        |this| {
466                                                            this.text_color(
467                                                                Color::Selected.color(cx),
468                                                            )
469                                                        },
470                                                    ),
471                                            ),
472                                    )
473                                    .on_click(cx.listener(move |this, _, _, cx| {
474                                        this.navbar_entry = ix;
475                                        cx.notify();
476                                    }))
477                            })
478                            .collect()
479                    }),
480                )
481                .track_scroll(self.list_handle.clone())
482                .gap_1_5()
483                .size_full()
484                .flex_grow(),
485            )
486    }
487
488    fn render_page(
489        &self,
490        page: &SettingsPage,
491        window: &mut Window,
492        cx: &mut Context<SettingsWindow>,
493    ) -> Div {
494        v_flex().gap_4().py_4().children(
495            page.items
496                .iter()
497                .map(|item| item.render(self.current_file.clone(), window, cx)),
498        )
499    }
500
501    fn current_page(&self) -> &SettingsPage {
502        &self.pages[self.page_index_from_navbar_index(self.navbar_entry)]
503    }
504
505    fn page_index_from_navbar_index(&self, index: usize) -> usize {
506        self.navbar_entries
507            .iter()
508            .take(index + 1)
509            .map(|entry| entry.is_root as usize)
510            .sum::<usize>()
511            - 1
512    }
513
514    fn page_for_navbar_index(&mut self, index: usize) -> &mut SettingsPage {
515        let index = self.page_index_from_navbar_index(index);
516        &mut self.pages[index]
517    }
518
519    fn is_navbar_entry_selected(&self, ix: usize) -> bool {
520        ix == self.navbar_entry
521    }
522}
523
524impl Render for SettingsWindow {
525    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
526        div()
527            .size_full()
528            .bg(cx.theme().colors().background)
529            .flex()
530            .flex_row()
531            .text_color(cx.theme().colors().text)
532            .child(self.render_nav(window, cx).w(px(300.0)))
533            .child(Divider::vertical().color(ui::DividerColor::BorderVariant))
534            .child(
535                v_flex()
536                    .bg(cx.theme().colors().editor_background)
537                    .px_6()
538                    .py_2()
539                    .child(self.render_files(window, cx))
540                    .child(self.render_page(self.current_page(), window, cx))
541                    .w_full(),
542            )
543    }
544}
545
546fn write_setting_value<T: Send + 'static>(
547    get_value: fn(&mut SettingsContent) -> &mut Option<T>,
548    value: Option<T>,
549    cx: &mut App,
550) {
551    cx.update_global(|store: &mut SettingsStore, cx| {
552        store.update_settings_file(<dyn fs::Fs>::global(cx), move |settings, _cx| {
553            *get_value(settings) = value;
554        });
555    });
556}
557
558fn render_text_field(
559    id: &'static str,
560    _file: SettingsFile,
561    window: &mut Window,
562    cx: &mut App,
563    get_value: fn(&mut SettingsContent) -> &mut Option<String>,
564) -> AnyElement {
565    // TODO: Updating file does not cause the editor text to reload, suspicious it may be a missing global update/notify in SettingsStore
566
567    // TODO: in settings window state
568    let store = SettingsStore::global(cx);
569
570    // TODO: This clone needs to go!!
571    let mut defaults = store.raw_default_settings().clone();
572    let mut user_settings = store
573        .raw_user_settings()
574        .cloned()
575        .unwrap_or_default()
576        .content;
577
578    // TODO: unwrap_or_default here because project name is null
579    let initial_text = get_value(user_settings.as_mut())
580        .clone()
581        .unwrap_or_else(|| get_value(&mut defaults).clone().unwrap_or_default());
582
583    let editor = window.use_keyed_state((id.into(), initial_text.clone()), cx, {
584        move |window, cx| {
585            let mut editor = Editor::single_line(window, cx);
586            editor.set_text(initial_text, window, cx);
587            editor
588        }
589    });
590
591    let weak_editor = editor.downgrade();
592    let theme_colors = cx.theme().colors();
593
594    div()
595        .child(editor)
596        .bg(theme_colors.editor_background)
597        .border_1()
598        .rounded_lg()
599        .border_color(theme_colors.border)
600        .on_action::<menu::Confirm>({
601            move |_, _, cx| {
602                let Some(editor) = weak_editor.upgrade() else {
603                    return;
604                };
605                let new_value = editor.read_with(cx, |editor, cx| editor.text(cx));
606                let new_value = (!new_value.is_empty()).then_some(new_value);
607                write_setting_value(get_value, new_value, cx);
608                editor.update(cx, |_, cx| {
609                    cx.notify();
610                });
611            }
612        })
613        .into_any_element()
614}
615
616fn render_toggle_button<B: Into<bool> + From<bool> + Copy + Send + 'static>(
617    id: &'static str,
618    _: SettingsFile,
619    cx: &mut App,
620    get_value: fn(&mut SettingsContent) -> &mut Option<B>,
621) -> AnyElement {
622    // TODO: in settings window state
623    let store = SettingsStore::global(cx);
624
625    // TODO: This clone needs to go!!
626    let mut defaults = store.raw_default_settings().clone();
627    let mut user_settings = store
628        .raw_user_settings()
629        .cloned()
630        .unwrap_or_default()
631        .content;
632
633    let toggle_state = if get_value(&mut user_settings)
634        .unwrap_or_else(|| get_value(&mut defaults).unwrap())
635        .into()
636    {
637        ui::ToggleState::Selected
638    } else {
639        ui::ToggleState::Unselected
640    };
641
642    Switch::new(id, toggle_state)
643        .on_click({
644            move |state, _window, cx| {
645                write_setting_value(
646                    get_value,
647                    Some((*state == ui::ToggleState::Selected).into()),
648                    cx,
649                );
650            }
651        })
652        .into_any_element()
653}
654
655fn render_dropdown<T>(
656    id: &'static str,
657    _: SettingsFile,
658    window: &mut Window,
659    cx: &mut App,
660    get_value: fn(&mut SettingsContent) -> &mut Option<T>,
661) -> AnyElement
662where
663    T: strum::VariantArray + strum::VariantNames + Copy + PartialEq + Send + 'static,
664{
665    let variants = || -> &'static [T] { <T as strum::VariantArray>::VARIANTS };
666    let labels = || -> &'static [&'static str] { <T as strum::VariantNames>::VARIANTS };
667
668    let store = SettingsStore::global(cx);
669    let mut defaults = store.raw_default_settings().clone();
670    let mut user_settings = store
671        .raw_user_settings()
672        .cloned()
673        .unwrap_or_default()
674        .content;
675
676    let current_value =
677        get_value(&mut user_settings).unwrap_or_else(|| get_value(&mut defaults).unwrap());
678    let current_value_label =
679        labels()[variants().iter().position(|v| *v == current_value).unwrap()];
680
681    DropdownMenu::new(
682        id,
683        current_value_label,
684        ui::ContextMenu::build(window, cx, move |mut menu, _, _| {
685            for (value, label) in variants()
686                .into_iter()
687                .copied()
688                .zip(labels().into_iter().copied())
689            {
690                menu = menu.toggleable_entry(
691                    label,
692                    value == current_value,
693                    ui::IconPosition::Start,
694                    None,
695                    move |_, cx| {
696                        if value == current_value {
697                            return;
698                        }
699                        write_setting_value(get_value, Some(value), cx);
700                    },
701                );
702            }
703            menu
704        }),
705    )
706    .into_any_element()
707}
708
709#[cfg(test)]
710mod test {
711    use super::*;
712
713    impl SettingsWindow {
714        fn navbar(&self) -> &[NavBarEntry] {
715            self.navbar_entries.as_slice()
716        }
717
718        fn navbar_entry(&self) -> usize {
719            self.navbar_entry
720        }
721    }
722
723    fn register_settings(cx: &mut App) {
724        settings::init(cx);
725        theme::init(theme::LoadThemes::JustBase, cx);
726        workspace::init_settings(cx);
727        project::Project::init_settings(cx);
728        language::init(cx);
729        editor::init(cx);
730        menu::init();
731    }
732
733    fn parse(input: &'static str, window: &mut Window, cx: &mut App) -> SettingsWindow {
734        let mut pages: Vec<SettingsPage> = Vec::new();
735        let mut current_page = None;
736        let mut selected_idx = None;
737
738        for (ix, mut line) in input
739            .lines()
740            .map(|line| line.trim())
741            .filter(|line| !line.is_empty())
742            .enumerate()
743        {
744            if line.ends_with("*") {
745                assert!(
746                    selected_idx.is_none(),
747                    "Can only have one selected navbar entry at a time"
748                );
749                selected_idx = Some(ix);
750                line = &line[..line.len() - 1];
751            }
752
753            if line.starts_with("v") || line.starts_with(">") {
754                if let Some(current_page) = current_page.take() {
755                    pages.push(current_page);
756                }
757
758                let expanded = line.starts_with("v");
759
760                current_page = Some(SettingsPage {
761                    title: line.split_once(" ").unwrap().1,
762                    expanded,
763                    items: Vec::default(),
764                });
765            } else if line.starts_with("-") {
766                let Some(current_page) = current_page.as_mut() else {
767                    panic!("Sub entries must be within a page");
768                };
769
770                current_page.items.push(SettingsPageItem::SectionHeader(
771                    line.split_once(" ").unwrap().1,
772                ));
773            } else {
774                panic!(
775                    "Entries must start with one of 'v', '>', or '-'\n line: {}",
776                    line
777                );
778            }
779        }
780
781        if let Some(current_page) = current_page.take() {
782            pages.push(current_page);
783        }
784
785        let mut settings_window = SettingsWindow {
786            files: Vec::default(),
787            current_file: crate::SettingsFile::User,
788            pages,
789            search: cx.new(|cx| Editor::single_line(window, cx)),
790            navbar_entry: selected_idx.unwrap(),
791            navbar_entries: Vec::default(),
792            list_handle: UniformListScrollHandle::default(),
793        };
794
795        settings_window.build_navbar();
796        settings_window
797    }
798
799    #[track_caller]
800    fn check_navbar_toggle(
801        before: &'static str,
802        toggle_idx: usize,
803        after: &'static str,
804        window: &mut Window,
805        cx: &mut App,
806    ) {
807        let mut settings_window = parse(before, window, cx);
808        settings_window.toggle_navbar_entry(toggle_idx);
809
810        let expected_settings_window = parse(after, window, cx);
811
812        assert_eq!(settings_window.navbar(), expected_settings_window.navbar());
813        assert_eq!(
814            settings_window.navbar_entry(),
815            expected_settings_window.navbar_entry()
816        );
817    }
818
819    macro_rules! check_navbar_toggle {
820        ($name:ident, before: $before:expr, toggle_idx: $toggle_idx:expr, after: $after:expr) => {
821            #[gpui::test]
822            fn $name(cx: &mut gpui::TestAppContext) {
823                let window = cx.add_empty_window();
824                window.update(|window, cx| {
825                    register_settings(cx);
826                    check_navbar_toggle($before, $toggle_idx, $after, window, cx);
827                });
828            }
829        };
830    }
831
832    check_navbar_toggle!(
833        basic_open,
834        before: r"
835        v General
836        - General
837        - Privacy*
838        v Project
839        - Project Settings
840        ",
841        toggle_idx: 0,
842        after: r"
843        > General*
844        v Project
845        - Project Settings
846        "
847    );
848
849    check_navbar_toggle!(
850        basic_close,
851        before: r"
852        > General*
853        - General
854        - Privacy
855        v Project
856        - Project Settings
857        ",
858        toggle_idx: 0,
859        after: r"
860        v General*
861        - General
862        - Privacy
863        v Project
864        - Project Settings
865        "
866    );
867
868    check_navbar_toggle!(
869        basic_second_root_entry_close,
870        before: r"
871        > General
872        - General
873        - Privacy
874        v Project
875        - Project Settings*
876        ",
877        toggle_idx: 1,
878        after: r"
879        > General
880        > Project*
881        "
882    );
883}