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}