1//! # settings_ui
2mod components;
3mod page_data;
4
5use anyhow::Result;
6use editor::{Editor, EditorEvent};
7use feature_flags::{FeatureFlag, FeatureFlagAppExt as _};
8use fuzzy::StringMatchCandidate;
9use gpui::{
10 App, Div, Entity, Focusable, FontWeight, Global, ReadGlobal as _, ScrollHandle, Task,
11 TitlebarOptions, UniformListScrollHandle, Window, WindowHandle, WindowOptions, div, point,
12 prelude::*, px, size, uniform_list,
13};
14use project::WorktreeId;
15use settings::{
16 BottomDockLayout, CloseWindowWhenNoItems, CodeFade, CursorShape, OnLastWindowClosed,
17 RestoreOnStartupBehavior, SaturatingBool, SettingsContent, SettingsStore,
18};
19use std::{
20 any::{Any, TypeId, type_name},
21 cell::RefCell,
22 collections::HashMap,
23 num::NonZeroU32,
24 ops::Range,
25 rc::Rc,
26 sync::{Arc, atomic::AtomicBool},
27};
28use ui::{
29 ButtonLike, ContextMenu, Divider, DropdownMenu, DropdownStyle, IconButtonShape, PopoverMenu,
30 Switch, SwitchColor, TreeViewItem, WithScrollbar, prelude::*,
31};
32use ui_input::{NumericStepper, NumericStepperStyle, NumericStepperType};
33use util::{ResultExt as _, paths::PathStyle, rel_path::RelPath};
34use zed_actions::OpenSettingsEditor;
35
36use crate::components::SettingsEditor;
37
38#[derive(Clone, Copy)]
39struct SettingField<T: 'static> {
40 pick: fn(&SettingsContent) -> &Option<T>,
41 pick_mut: fn(&mut SettingsContent) -> &mut Option<T>,
42}
43
44trait AnySettingField {
45 fn as_any(&self) -> &dyn Any;
46 fn type_name(&self) -> &'static str;
47 fn type_id(&self) -> TypeId;
48 fn file_set_in(&self, file: SettingsUiFile, cx: &App) -> settings::SettingsFile;
49}
50
51impl<T> AnySettingField for SettingField<T> {
52 fn as_any(&self) -> &dyn Any {
53 self
54 }
55
56 fn type_name(&self) -> &'static str {
57 type_name::<T>()
58 }
59
60 fn type_id(&self) -> TypeId {
61 TypeId::of::<T>()
62 }
63
64 fn file_set_in(&self, file: SettingsUiFile, cx: &App) -> settings::SettingsFile {
65 let (file, _) = cx
66 .global::<SettingsStore>()
67 .get_value_from_file(file.to_settings(), self.pick);
68 return file;
69 }
70}
71
72#[derive(Default, Clone)]
73struct SettingFieldRenderer {
74 renderers: Rc<
75 RefCell<
76 HashMap<
77 TypeId,
78 Box<
79 dyn Fn(
80 &dyn AnySettingField,
81 SettingsUiFile,
82 Option<&SettingsFieldMetadata>,
83 &mut Window,
84 &mut App,
85 ) -> AnyElement,
86 >,
87 >,
88 >,
89 >,
90}
91
92impl Global for SettingFieldRenderer {}
93
94impl SettingFieldRenderer {
95 fn add_renderer<T: 'static>(
96 &mut self,
97 renderer: impl Fn(
98 &SettingField<T>,
99 SettingsUiFile,
100 Option<&SettingsFieldMetadata>,
101 &mut Window,
102 &mut App,
103 ) -> AnyElement
104 + 'static,
105 ) -> &mut Self {
106 let key = TypeId::of::<T>();
107 let renderer = Box::new(
108 move |any_setting_field: &dyn AnySettingField,
109 settings_file: SettingsUiFile,
110 metadata: Option<&SettingsFieldMetadata>,
111 window: &mut Window,
112 cx: &mut App| {
113 let field = any_setting_field
114 .as_any()
115 .downcast_ref::<SettingField<T>>()
116 .unwrap();
117 renderer(field, settings_file, metadata, window, cx)
118 },
119 );
120 self.renderers.borrow_mut().insert(key, renderer);
121 self
122 }
123
124 fn render(
125 &self,
126 any_setting_field: &dyn AnySettingField,
127 settings_file: SettingsUiFile,
128 metadata: Option<&SettingsFieldMetadata>,
129 window: &mut Window,
130 cx: &mut App,
131 ) -> AnyElement {
132 let key = any_setting_field.type_id();
133 if let Some(renderer) = self.renderers.borrow().get(&key) {
134 renderer(any_setting_field, settings_file, metadata, window, cx)
135 } else {
136 panic!(
137 "No renderer found for type: {}",
138 any_setting_field.type_name()
139 )
140 }
141 }
142}
143
144struct SettingsFieldMetadata {
145 placeholder: Option<&'static str>,
146}
147
148pub struct SettingsUiFeatureFlag;
149
150impl FeatureFlag for SettingsUiFeatureFlag {
151 const NAME: &'static str = "settings-ui";
152}
153
154pub fn init(cx: &mut App) {
155 init_renderers(cx);
156
157 cx.observe_new(|workspace: &mut workspace::Workspace, _, _| {
158 workspace.register_action_renderer(|div, _, _, cx| {
159 let settings_ui_actions = [std::any::TypeId::of::<OpenSettingsEditor>()];
160 let has_flag = cx.has_flag::<SettingsUiFeatureFlag>();
161 command_palette_hooks::CommandPaletteFilter::update_global(cx, |filter, _| {
162 if has_flag {
163 filter.show_action_types(&settings_ui_actions);
164 } else {
165 filter.hide_action_types(&settings_ui_actions);
166 }
167 });
168 if has_flag {
169 div.on_action(cx.listener(|_, _: &OpenSettingsEditor, _, cx| {
170 open_settings_editor(cx).ok();
171 }))
172 } else {
173 div
174 }
175 });
176 })
177 .detach();
178}
179
180fn init_renderers(cx: &mut App) {
181 // fn (field: SettingsField, current_file: SettingsFile, cx) -> (currently_set_in: SettingsFile, overridden_in: Vec<SettingsFile>)
182 cx.default_global::<SettingFieldRenderer>()
183 .add_renderer::<bool>(|settings_field, file, _, _, cx| {
184 render_toggle_button(*settings_field, file, cx).into_any_element()
185 })
186 .add_renderer::<String>(|settings_field, file, metadata, _, cx| {
187 render_text_field(settings_field.clone(), file, metadata, cx)
188 })
189 .add_renderer::<SaturatingBool>(|settings_field, file, _, _, cx| {
190 render_toggle_button(*settings_field, file, cx)
191 })
192 .add_renderer::<CursorShape>(|settings_field, file, _, window, cx| {
193 render_dropdown(*settings_field, file, window, cx)
194 })
195 .add_renderer::<RestoreOnStartupBehavior>(|settings_field, file, _, window, cx| {
196 render_dropdown(*settings_field, file, window, cx)
197 })
198 .add_renderer::<BottomDockLayout>(|settings_field, file, _, window, cx| {
199 render_dropdown(*settings_field, file, window, cx)
200 })
201 .add_renderer::<OnLastWindowClosed>(|settings_field, file, _, window, cx| {
202 render_dropdown(*settings_field, file, window, cx)
203 })
204 .add_renderer::<CloseWindowWhenNoItems>(|settings_field, file, _, window, cx| {
205 render_dropdown(*settings_field, file, window, cx)
206 })
207 .add_renderer::<settings::FontFamilyName>(|settings_field, file, _, window, cx| {
208 // todo(settings_ui): We need to pass in a validator for this to ensure that users that type in invalid font names
209 render_font_picker(settings_field.clone(), file, window, cx)
210 })
211 .add_renderer::<settings::BufferLineHeight>(|settings_field, file, _, window, cx| {
212 // todo(settings_ui): Do we want to expose the custom variant of buffer line height?
213 // right now there's a manual impl of strum::VariantArray
214 render_dropdown(*settings_field, file, window, cx)
215 })
216 .add_renderer::<settings::BaseKeymapContent>(|settings_field, file, _, window, cx| {
217 render_dropdown(*settings_field, file, window, cx)
218 })
219 .add_renderer::<settings::MultiCursorModifier>(|settings_field, file, _, window, cx| {
220 render_dropdown(*settings_field, file, window, cx)
221 })
222 .add_renderer::<settings::HideMouseMode>(|settings_field, file, _, window, cx| {
223 render_dropdown(*settings_field, file, window, cx)
224 })
225 .add_renderer::<settings::CurrentLineHighlight>(|settings_field, file, _, window, cx| {
226 render_dropdown(*settings_field, file, window, cx)
227 })
228 .add_renderer::<settings::ShowWhitespaceSetting>(|settings_field, file, _, window, cx| {
229 render_dropdown(*settings_field, file, window, cx)
230 })
231 .add_renderer::<settings::SoftWrap>(|settings_field, file, _, window, cx| {
232 render_dropdown(*settings_field, file, window, cx)
233 })
234 .add_renderer::<settings::ScrollBeyondLastLine>(|settings_field, file, _, window, cx| {
235 render_dropdown(*settings_field, file, window, cx)
236 })
237 .add_renderer::<settings::SnippetSortOrder>(|settings_field, file, _, window, cx| {
238 render_dropdown(*settings_field, file, window, cx)
239 })
240 .add_renderer::<settings::ClosePosition>(|settings_field, file, _, window, cx| {
241 render_dropdown(*settings_field, file, window, cx)
242 })
243 .add_renderer::<settings::DockSide>(|settings_field, file, _, window, cx| {
244 render_dropdown(*settings_field, file, window, cx)
245 })
246 .add_renderer::<settings::TerminalDockPosition>(|settings_field, file, _, window, cx| {
247 render_dropdown(*settings_field, file, window, cx)
248 })
249 .add_renderer::<settings::GitGutterSetting>(|settings_field, file, _, window, cx| {
250 render_dropdown(*settings_field, file, window, cx)
251 })
252 .add_renderer::<settings::GitHunkStyleSetting>(|settings_field, file, _, window, cx| {
253 render_dropdown(*settings_field, file, window, cx)
254 })
255 .add_renderer::<settings::DiagnosticSeverityContent>(
256 |settings_field, file, _, window, cx| {
257 render_dropdown(*settings_field, file, window, cx)
258 },
259 )
260 .add_renderer::<settings::SeedQuerySetting>(|settings_field, file, _, window, cx| {
261 render_dropdown(*settings_field, file, window, cx)
262 })
263 .add_renderer::<settings::DoubleClickInMultibuffer>(
264 |settings_field, file, _, window, cx| {
265 render_dropdown(*settings_field, file, window, cx)
266 },
267 )
268 .add_renderer::<settings::GoToDefinitionFallback>(|settings_field, file, _, window, cx| {
269 render_dropdown(*settings_field, file, window, cx)
270 })
271 .add_renderer::<settings::ActivateOnClose>(|settings_field, file, _, window, cx| {
272 render_dropdown(*settings_field, file, window, cx)
273 })
274 .add_renderer::<settings::ShowDiagnostics>(|settings_field, file, _, window, cx| {
275 render_dropdown(*settings_field, file, window, cx)
276 })
277 .add_renderer::<settings::ShowCloseButton>(|settings_field, file, _, window, cx| {
278 render_dropdown(*settings_field, file, window, cx)
279 })
280 .add_renderer::<f32>(|settings_field, file, _, window, cx| {
281 render_numeric_stepper(*settings_field, file, window, cx)
282 })
283 .add_renderer::<u32>(|settings_field, file, _, window, cx| {
284 render_numeric_stepper(*settings_field, file, window, cx)
285 })
286 .add_renderer::<u64>(|settings_field, file, _, window, cx| {
287 render_numeric_stepper(*settings_field, file, window, cx)
288 })
289 .add_renderer::<NonZeroU32>(|settings_field, file, _, window, cx| {
290 render_numeric_stepper(*settings_field, file, window, cx)
291 })
292 .add_renderer::<CodeFade>(|settings_field, file, _, window, cx| {
293 render_numeric_stepper(*settings_field, file, window, cx)
294 })
295 .add_renderer::<FontWeight>(|settings_field, file, _, window, cx| {
296 render_numeric_stepper(*settings_field, file, window, cx)
297 });
298
299 // todo(settings_ui): Figure out how we want to handle discriminant unions
300 // .add_renderer::<ThemeSelection>(|settings_field, file, _, window, cx| {
301 // render_dropdown(*settings_field, file, window, cx)
302 // });
303}
304
305pub fn open_settings_editor(cx: &mut App) -> anyhow::Result<WindowHandle<SettingsWindow>> {
306 cx.open_window(
307 WindowOptions {
308 titlebar: Some(TitlebarOptions {
309 title: Some("Settings Window".into()),
310 appears_transparent: true,
311 traffic_light_position: Some(point(px(12.0), px(12.0))),
312 }),
313 focus: true,
314 show: true,
315 kind: gpui::WindowKind::Normal,
316 window_background: cx.theme().window_background_appearance(),
317 window_min_size: Some(size(px(800.), px(600.))), // 4:3 Aspect Ratio
318 ..Default::default()
319 },
320 |window, cx| cx.new(|cx| SettingsWindow::new(window, cx)),
321 )
322}
323
324pub struct SettingsWindow {
325 files: Vec<SettingsUiFile>,
326 current_file: SettingsUiFile,
327 pages: Vec<SettingsPage>,
328 search_bar: Entity<Editor>,
329 search_task: Option<Task<()>>,
330 navbar_entry: usize, // Index into pages - should probably be (usize, Option<usize>) for section + page
331 navbar_entries: Vec<NavBarEntry>,
332 list_handle: UniformListScrollHandle,
333 search_matches: Vec<Vec<bool>>,
334 /// The current sub page path that is selected.
335 /// If this is empty the selected page is rendered,
336 /// otherwise the last sub page gets rendered.
337 sub_page_stack: Vec<SubPage>,
338 scroll_handle: ScrollHandle,
339}
340
341struct SubPage {
342 link: SubPageLink,
343 section_header: &'static str,
344}
345
346#[derive(PartialEq, Debug)]
347struct NavBarEntry {
348 title: &'static str,
349 is_root: bool,
350 expanded: bool,
351 page_index: usize,
352 item_index: Option<usize>,
353}
354
355struct SettingsPage {
356 title: &'static str,
357 items: Vec<SettingsPageItem>,
358}
359
360#[derive(PartialEq)]
361enum SettingsPageItem {
362 SectionHeader(&'static str),
363 SettingItem(SettingItem),
364 SubPageLink(SubPageLink),
365}
366
367impl std::fmt::Debug for SettingsPageItem {
368 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
369 match self {
370 SettingsPageItem::SectionHeader(header) => write!(f, "SectionHeader({})", header),
371 SettingsPageItem::SettingItem(setting_item) => {
372 write!(f, "SettingItem({})", setting_item.title)
373 }
374 SettingsPageItem::SubPageLink(sub_page_link) => {
375 write!(f, "SubPageLink({})", sub_page_link.title)
376 }
377 }
378 }
379}
380
381impl SettingsPageItem {
382 fn render(
383 &self,
384 file: SettingsUiFile,
385 section_header: &'static str,
386 is_last: bool,
387 window: &mut Window,
388 cx: &mut Context<SettingsWindow>,
389 ) -> AnyElement {
390 match self {
391 SettingsPageItem::SectionHeader(header) => v_flex()
392 .w_full()
393 .gap_1()
394 .child(
395 Label::new(SharedString::new_static(header))
396 .size(LabelSize::XSmall)
397 .color(Color::Muted)
398 .buffer_font(cx),
399 )
400 .child(Divider::horizontal().color(ui::DividerColor::BorderVariant))
401 .into_any_element(),
402 SettingsPageItem::SettingItem(setting_item) => {
403 let renderer = cx.default_global::<SettingFieldRenderer>().clone();
404 let file_set_in =
405 SettingsUiFile::from_settings(setting_item.field.file_set_in(file.clone(), cx));
406
407 h_flex()
408 .id(setting_item.title)
409 .w_full()
410 .gap_2()
411 .flex_wrap()
412 .justify_between()
413 .map(|this| {
414 if is_last {
415 this.pb_6()
416 } else {
417 this.pb_4()
418 .border_b_1()
419 .border_color(cx.theme().colors().border_variant)
420 }
421 })
422 .child(
423 v_flex()
424 .max_w_1_2()
425 .flex_shrink()
426 .child(
427 h_flex()
428 .w_full()
429 .gap_1()
430 .child(Label::new(SharedString::new_static(setting_item.title)))
431 .when_some(
432 file_set_in.filter(|file_set_in| file_set_in != &file),
433 |this, file_set_in| {
434 this.child(
435 Label::new(format!(
436 "— set in {}",
437 file_set_in.name()
438 ))
439 .color(Color::Muted)
440 .size(LabelSize::Small),
441 )
442 },
443 ),
444 )
445 .child(
446 Label::new(SharedString::new_static(setting_item.description))
447 .size(LabelSize::Small)
448 .color(Color::Muted),
449 ),
450 )
451 .child(renderer.render(
452 setting_item.field.as_ref(),
453 file,
454 setting_item.metadata.as_deref(),
455 window,
456 cx,
457 ))
458 .into_any_element()
459 }
460 SettingsPageItem::SubPageLink(sub_page_link) => h_flex()
461 .id(sub_page_link.title)
462 .w_full()
463 .gap_2()
464 .flex_wrap()
465 .justify_between()
466 .when(!is_last, |this| {
467 this.pb_4()
468 .border_b_1()
469 .border_color(cx.theme().colors().border_variant)
470 })
471 .child(
472 v_flex()
473 .max_w_1_2()
474 .flex_shrink()
475 .child(Label::new(SharedString::new_static(sub_page_link.title))),
476 )
477 .child(
478 Button::new(("sub-page".into(), sub_page_link.title), "Configure")
479 .size(ButtonSize::Medium)
480 .icon(IconName::ChevronRight)
481 .icon_position(IconPosition::End)
482 .icon_color(Color::Muted)
483 .icon_size(IconSize::Small)
484 .style(ButtonStyle::Outlined),
485 )
486 .on_click({
487 let sub_page_link = sub_page_link.clone();
488 cx.listener(move |this, _, _, cx| {
489 this.push_sub_page(sub_page_link.clone(), section_header, cx)
490 })
491 })
492 .into_any_element(),
493 }
494 }
495}
496
497struct SettingItem {
498 title: &'static str,
499 description: &'static str,
500 field: Box<dyn AnySettingField>,
501 metadata: Option<Box<SettingsFieldMetadata>>,
502}
503
504impl PartialEq for SettingItem {
505 fn eq(&self, other: &Self) -> bool {
506 self.title == other.title
507 && self.description == other.description
508 && (match (&self.metadata, &other.metadata) {
509 (None, None) => true,
510 (Some(m1), Some(m2)) => m1.placeholder == m2.placeholder,
511 _ => false,
512 })
513 }
514}
515
516#[derive(Clone)]
517struct SubPageLink {
518 title: &'static str,
519 render: Rc<dyn Fn(&mut SettingsWindow, &mut Window, &mut App) -> AnyElement>,
520}
521
522impl PartialEq for SubPageLink {
523 fn eq(&self, other: &Self) -> bool {
524 self.title == other.title
525 }
526}
527
528#[allow(unused)]
529#[derive(Clone, PartialEq)]
530enum SettingsUiFile {
531 User, // Uses all settings.
532 Local((WorktreeId, Arc<RelPath>)), // Has a special name, and special set of settings
533 Server(&'static str), // Uses a special name, and the user settings
534}
535
536impl SettingsUiFile {
537 fn pages(&self) -> Vec<SettingsPage> {
538 match self {
539 SettingsUiFile::User => page_data::user_settings_data(),
540 SettingsUiFile::Local(_) => page_data::project_settings_data(),
541 SettingsUiFile::Server(_) => page_data::user_settings_data(),
542 }
543 }
544
545 fn name(&self) -> SharedString {
546 match self {
547 SettingsUiFile::User => SharedString::new_static("User"),
548 // TODO is PathStyle::local() ever not appropriate?
549 SettingsUiFile::Local((_, path)) => {
550 format!("Local ({})", path.display(PathStyle::local())).into()
551 }
552 SettingsUiFile::Server(file) => format!("Server ({})", file).into(),
553 }
554 }
555
556 fn from_settings(file: settings::SettingsFile) -> Option<Self> {
557 Some(match file {
558 settings::SettingsFile::User => SettingsUiFile::User,
559 settings::SettingsFile::Local(location) => SettingsUiFile::Local(location),
560 settings::SettingsFile::Server => SettingsUiFile::Server("todo: server name"),
561 settings::SettingsFile::Default => return None,
562 })
563 }
564
565 fn to_settings(&self) -> settings::SettingsFile {
566 match self {
567 SettingsUiFile::User => settings::SettingsFile::User,
568 SettingsUiFile::Local(location) => settings::SettingsFile::Local(location.clone()),
569 SettingsUiFile::Server(_) => settings::SettingsFile::Server,
570 }
571 }
572}
573
574impl SettingsWindow {
575 pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
576 let font_family_cache = theme::FontFamilyCache::global(cx);
577
578 cx.spawn(async move |this, cx| {
579 font_family_cache.prefetch(cx).await;
580 this.update(cx, |_, cx| {
581 cx.notify();
582 })
583 })
584 .detach();
585
586 let current_file = SettingsUiFile::User;
587 let search_bar = cx.new(|cx| {
588 let mut editor = Editor::single_line(window, cx);
589 editor.set_placeholder_text("Search settings…", window, cx);
590 editor
591 });
592
593 cx.subscribe(&search_bar, |this, _, event: &EditorEvent, cx| {
594 let EditorEvent::Edited { transaction_id: _ } = event else {
595 return;
596 };
597
598 this.update_matches(cx);
599 })
600 .detach();
601
602 cx.observe_global_in::<SettingsStore>(window, move |this, _, cx| {
603 this.fetch_files(cx);
604 cx.notify();
605 })
606 .detach();
607
608 let mut this = Self {
609 files: vec![],
610 current_file: current_file,
611 pages: vec![],
612 navbar_entries: vec![],
613 navbar_entry: 0,
614 list_handle: UniformListScrollHandle::default(),
615 search_bar,
616 search_task: None,
617 search_matches: vec![],
618 sub_page_stack: vec![],
619 scroll_handle: ScrollHandle::new(),
620 };
621
622 this.fetch_files(cx);
623 this.build_ui(cx);
624
625 this.search_bar.update(cx, |editor, cx| {
626 editor.focus_handle(cx).focus(window);
627 });
628
629 this
630 }
631
632 fn toggle_navbar_entry(&mut self, ix: usize) {
633 // We can only toggle root entries
634 if !self.navbar_entries[ix].is_root {
635 return;
636 }
637
638 let toggle_page_index = self.page_index_from_navbar_index(ix);
639 let selected_page_index = self.page_index_from_navbar_index(self.navbar_entry);
640
641 let expanded = &mut self.navbar_entries[ix].expanded;
642 *expanded = !*expanded;
643 // if currently selected page is a child of the parent page we are folding,
644 // set the current page to the parent page
645 if !*expanded && selected_page_index == toggle_page_index {
646 self.navbar_entry = ix;
647 }
648 }
649
650 fn build_navbar(&mut self) {
651 let mut navbar_entries = Vec::with_capacity(self.navbar_entries.len());
652 for (page_index, page) in self.pages.iter().enumerate() {
653 navbar_entries.push(NavBarEntry {
654 title: page.title,
655 is_root: true,
656 expanded: false,
657 page_index,
658 item_index: None,
659 });
660
661 for (item_index, item) in page.items.iter().enumerate() {
662 let SettingsPageItem::SectionHeader(title) = item else {
663 continue;
664 };
665 navbar_entries.push(NavBarEntry {
666 title,
667 is_root: false,
668 expanded: false,
669 page_index,
670 item_index: Some(item_index),
671 });
672 }
673 }
674 self.navbar_entries = navbar_entries;
675 }
676
677 fn visible_navbar_entries(&self) -> impl Iterator<Item = (usize, &NavBarEntry)> {
678 let mut index = 0;
679 let entries = &self.navbar_entries;
680 let search_matches = &self.search_matches;
681 std::iter::from_fn(move || {
682 while index < entries.len() {
683 let entry = &entries[index];
684 let included_in_search = if let Some(item_index) = entry.item_index {
685 search_matches[entry.page_index][item_index]
686 } else {
687 search_matches[entry.page_index].iter().any(|b| *b)
688 || search_matches[entry.page_index].is_empty()
689 };
690 if included_in_search {
691 break;
692 }
693 index += 1;
694 }
695 if index >= self.navbar_entries.len() {
696 return None;
697 }
698 let entry = &entries[index];
699 let entry_index = index;
700
701 index += 1;
702 if entry.is_root && !entry.expanded {
703 while index < entries.len() {
704 if entries[index].is_root {
705 break;
706 }
707 index += 1;
708 }
709 }
710
711 return Some((entry_index, entry));
712 })
713 }
714
715 fn update_matches(&mut self, cx: &mut Context<SettingsWindow>) {
716 self.search_task.take();
717 let query = self.search_bar.read(cx).text(cx);
718 if query.is_empty() {
719 for page in &mut self.search_matches {
720 page.fill(true);
721 }
722 cx.notify();
723 return;
724 }
725
726 struct ItemKey {
727 page_index: usize,
728 header_index: usize,
729 item_index: usize,
730 }
731 let mut key_lut: Vec<ItemKey> = vec![];
732 let mut candidates = Vec::default();
733
734 for (page_index, page) in self.pages.iter().enumerate() {
735 let mut header_index = 0;
736 for (item_index, item) in page.items.iter().enumerate() {
737 let key_index = key_lut.len();
738 match item {
739 SettingsPageItem::SettingItem(item) => {
740 candidates.push(StringMatchCandidate::new(key_index, item.title));
741 candidates.push(StringMatchCandidate::new(key_index, item.description));
742 }
743 SettingsPageItem::SectionHeader(header) => {
744 candidates.push(StringMatchCandidate::new(key_index, header));
745 header_index = item_index;
746 }
747 SettingsPageItem::SubPageLink(sub_page_link) => {
748 candidates.push(StringMatchCandidate::new(key_index, sub_page_link.title));
749 }
750 }
751 key_lut.push(ItemKey {
752 page_index,
753 header_index,
754 item_index,
755 });
756 }
757 }
758 let atomic_bool = AtomicBool::new(false);
759
760 self.search_task = Some(cx.spawn(async move |this, cx| {
761 let string_matches = fuzzy::match_strings(
762 candidates.as_slice(),
763 &query,
764 false,
765 true,
766 candidates.len(),
767 &atomic_bool,
768 cx.background_executor().clone(),
769 );
770 let string_matches = string_matches.await;
771
772 this.update(cx, |this, cx| {
773 for page in &mut this.search_matches {
774 page.fill(false);
775 }
776
777 for string_match in string_matches {
778 let ItemKey {
779 page_index,
780 header_index,
781 item_index,
782 } = key_lut[string_match.candidate_id];
783 let page = &mut this.search_matches[page_index];
784 page[header_index] = true;
785 page[item_index] = true;
786 }
787 let first_navbar_entry_index = this
788 .visible_navbar_entries()
789 .next()
790 .map(|e| e.0)
791 .unwrap_or(0);
792 this.navbar_entry = first_navbar_entry_index;
793 cx.notify();
794 })
795 .ok();
796 }));
797 }
798
799 fn build_search_matches(&mut self) {
800 self.search_matches = self
801 .pages
802 .iter()
803 .map(|page| vec![true; page.items.len()])
804 .collect::<Vec<_>>();
805 }
806
807 fn build_ui(&mut self, cx: &mut Context<SettingsWindow>) {
808 self.pages = self.current_file.pages();
809 self.build_search_matches();
810 self.build_navbar();
811
812 if !self.search_bar.read(cx).is_empty(cx) {
813 self.update_matches(cx);
814 }
815
816 cx.notify();
817 }
818
819 fn fetch_files(&mut self, cx: &mut Context<SettingsWindow>) {
820 let settings_store = cx.global::<SettingsStore>();
821 let mut ui_files = vec![];
822 let all_files = settings_store.get_all_files();
823 for file in all_files {
824 let Some(settings_ui_file) = SettingsUiFile::from_settings(file) else {
825 continue;
826 };
827 ui_files.push(settings_ui_file);
828 }
829 ui_files.reverse();
830 self.files = ui_files;
831 if !self.files.contains(&self.current_file) {
832 self.change_file(0, cx);
833 }
834 }
835
836 fn change_file(&mut self, ix: usize, cx: &mut Context<SettingsWindow>) {
837 if ix >= self.files.len() {
838 self.current_file = SettingsUiFile::User;
839 return;
840 }
841 if self.files[ix] == self.current_file {
842 return;
843 }
844 self.current_file = self.files[ix].clone();
845 self.navbar_entry = 0;
846 self.build_ui(cx);
847 }
848
849 fn render_files(&self, _window: &mut Window, cx: &mut Context<SettingsWindow>) -> Div {
850 h_flex()
851 .gap_1()
852 .children(self.files.iter().enumerate().map(|(ix, file)| {
853 Button::new(ix, file.name())
854 .toggle_state(file == &self.current_file)
855 .selected_style(ButtonStyle::Tinted(ui::TintColor::Accent))
856 .on_click(cx.listener(move |this, _, _window, cx| this.change_file(ix, cx)))
857 }))
858 }
859
860 fn render_search(&self, _window: &mut Window, cx: &mut App) -> Div {
861 h_flex()
862 .py_1()
863 .px_1p5()
864 .gap_1p5()
865 .rounded_sm()
866 .bg(cx.theme().colors().editor_background)
867 .border_1()
868 .border_color(cx.theme().colors().border)
869 .child(Icon::new(IconName::MagnifyingGlass).color(Color::Muted))
870 .child(self.search_bar.clone())
871 }
872
873 fn render_nav(
874 &self,
875 window: &mut Window,
876 cx: &mut Context<SettingsWindow>,
877 ) -> impl IntoElement {
878 let visible_entries: Vec<_> = self.visible_navbar_entries().collect();
879 let visible_count = visible_entries.len();
880
881 v_flex()
882 .w_64()
883 .p_2p5()
884 .pt_10()
885 .gap_3()
886 .flex_none()
887 .border_r_1()
888 .border_color(cx.theme().colors().border)
889 .bg(cx.theme().colors().panel_background)
890 .child(self.render_search(window, cx))
891 .child(
892 v_flex()
893 .size_full()
894 .child(
895 uniform_list(
896 "settings-ui-nav-bar",
897 visible_count,
898 cx.processor(move |this, range: Range<usize>, _, cx| {
899 let entries: Vec<_> = this.visible_navbar_entries().collect();
900 range
901 .filter_map(|ix| entries.get(ix).copied())
902 .map(|(ix, entry)| {
903 TreeViewItem::new(
904 ("settings-ui-navbar-entry", ix),
905 entry.title,
906 )
907 .root_item(entry.is_root)
908 .toggle_state(this.is_navbar_entry_selected(ix))
909 .when(entry.is_root, |item| {
910 item.expanded(entry.expanded).on_toggle(cx.listener(
911 move |this, _, _, cx| {
912 this.toggle_navbar_entry(ix);
913 cx.notify();
914 },
915 ))
916 })
917 .on_click(cx.listener(move |this, _, _, cx| {
918 this.navbar_entry = ix;
919 cx.notify();
920 }))
921 .into_any_element()
922 })
923 .collect()
924 }),
925 )
926 .track_scroll(self.list_handle.clone())
927 .flex_grow(),
928 )
929 .vertical_scrollbar_for(self.list_handle.clone(), window, cx),
930 )
931 }
932
933 fn page_items(&self) -> impl Iterator<Item = &SettingsPageItem> {
934 let page_idx = self.current_page_index();
935
936 self.current_page()
937 .items
938 .iter()
939 .enumerate()
940 .filter_map(move |(item_index, item)| {
941 self.search_matches[page_idx][item_index].then_some(item)
942 })
943 }
944
945 fn render_sub_page_breadcrumbs(&self) -> impl IntoElement {
946 let mut items = vec![];
947 items.push(self.current_page().title);
948 items.extend(
949 self.sub_page_stack
950 .iter()
951 .flat_map(|page| [page.section_header, page.link.title]),
952 );
953
954 let last = items.pop().unwrap();
955 h_flex()
956 .gap_1()
957 .children(
958 items
959 .into_iter()
960 .flat_map(|item| [item, "/"])
961 .map(|item| Label::new(item).color(Color::Muted)),
962 )
963 .child(Label::new(last))
964 }
965
966 fn render_page(
967 &mut self,
968 window: &mut Window,
969 cx: &mut Context<SettingsWindow>,
970 ) -> impl IntoElement {
971 let mut page = v_flex()
972 .w_full()
973 .pt_4()
974 .pb_6()
975 .px_6()
976 .gap_4()
977 .bg(cx.theme().colors().editor_background)
978 .vertical_scrollbar_for(self.scroll_handle.clone(), window, cx);
979
980 let mut page_content = v_flex()
981 .id("settings-ui-page")
982 .size_full()
983 .gap_4()
984 .overflow_y_scroll()
985 .track_scroll(&self.scroll_handle);
986
987 if self.sub_page_stack.len() == 0 {
988 page = page.child(self.render_files(window, cx));
989
990 let items: Vec<_> = self.page_items().collect();
991 let items_len = items.len();
992 let mut section_header = None;
993
994 let search_query = self.search_bar.read(cx).text(cx);
995 let has_active_search = !search_query.is_empty();
996 let has_no_results = items_len == 0 && has_active_search;
997
998 if has_no_results {
999 page_content = page_content.child(
1000 v_flex()
1001 .size_full()
1002 .items_center()
1003 .justify_center()
1004 .gap_1()
1005 .child(div().child("No Results"))
1006 .child(
1007 div()
1008 .text_sm()
1009 .text_color(cx.theme().colors().text_muted)
1010 .child(format!("No settings match \"{}\"", search_query)),
1011 ),
1012 )
1013 } else {
1014 let last_non_header_index = items
1015 .iter()
1016 .enumerate()
1017 .rev()
1018 .find(|(_, item)| !matches!(item, SettingsPageItem::SectionHeader(_)))
1019 .map(|(index, _)| index);
1020
1021 page_content = page_content.children(items.clone().into_iter().enumerate().map(
1022 |(index, item)| {
1023 let no_bottom_border = items
1024 .get(index + 1)
1025 .map(|next_item| {
1026 matches!(next_item, SettingsPageItem::SectionHeader(_))
1027 })
1028 .unwrap_or(false);
1029 let is_last = Some(index) == last_non_header_index;
1030
1031 if let SettingsPageItem::SectionHeader(header) = item {
1032 section_header = Some(*header);
1033 }
1034 item.render(
1035 self.current_file.clone(),
1036 section_header.expect("All items rendered after a section header"),
1037 no_bottom_border || is_last,
1038 window,
1039 cx,
1040 )
1041 },
1042 ))
1043 }
1044 } else {
1045 page = page.child(
1046 h_flex()
1047 .ml_neg_1p5()
1048 .gap_1()
1049 .child(
1050 IconButton::new("back-btn", IconName::ArrowLeft)
1051 .icon_size(IconSize::Small)
1052 .shape(IconButtonShape::Square)
1053 .on_click(cx.listener(|this, _, _, cx| {
1054 this.pop_sub_page(cx);
1055 })),
1056 )
1057 .child(self.render_sub_page_breadcrumbs()),
1058 );
1059
1060 let active_page_render_fn = self.sub_page_stack.last().unwrap().link.render.clone();
1061 page_content = page_content.child((active_page_render_fn)(self, window, cx));
1062 }
1063
1064 return page.child(page_content);
1065 }
1066
1067 fn current_page_index(&self) -> usize {
1068 self.page_index_from_navbar_index(self.navbar_entry)
1069 }
1070
1071 fn current_page(&self) -> &SettingsPage {
1072 &self.pages[self.current_page_index()]
1073 }
1074
1075 fn page_index_from_navbar_index(&self, index: usize) -> usize {
1076 if self.navbar_entries.is_empty() {
1077 return 0;
1078 }
1079
1080 self.navbar_entries[index].page_index
1081 }
1082
1083 fn is_navbar_entry_selected(&self, ix: usize) -> bool {
1084 ix == self.navbar_entry
1085 }
1086
1087 fn push_sub_page(
1088 &mut self,
1089 sub_page_link: SubPageLink,
1090 section_header: &'static str,
1091 cx: &mut Context<SettingsWindow>,
1092 ) {
1093 self.sub_page_stack.push(SubPage {
1094 link: sub_page_link,
1095 section_header,
1096 });
1097 cx.notify();
1098 }
1099
1100 fn pop_sub_page(&mut self, cx: &mut Context<SettingsWindow>) {
1101 self.sub_page_stack.pop();
1102 cx.notify();
1103 }
1104}
1105
1106impl Render for SettingsWindow {
1107 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1108 let ui_font = theme::setup_ui_font(window, cx);
1109
1110 div()
1111 .key_context("SettingsWindow")
1112 .flex()
1113 .flex_row()
1114 .size_full()
1115 .font(ui_font)
1116 .bg(cx.theme().colors().background)
1117 .text_color(cx.theme().colors().text)
1118 .child(self.render_nav(window, cx))
1119 .child(self.render_page(window, cx))
1120 }
1121}
1122
1123fn update_settings_file(
1124 file: SettingsUiFile,
1125 cx: &mut App,
1126 update: impl 'static + Send + FnOnce(&mut SettingsContent, &App),
1127) -> Result<()> {
1128 match file {
1129 SettingsUiFile::Local((worktree_id, rel_path)) => {
1130 fn all_projects(cx: &App) -> impl Iterator<Item = Entity<project::Project>> {
1131 workspace::AppState::global(cx)
1132 .upgrade()
1133 .map(|app_state| {
1134 app_state
1135 .workspace_store
1136 .read(cx)
1137 .workspaces()
1138 .iter()
1139 .filter_map(|workspace| {
1140 Some(workspace.read(cx).ok()?.project().clone())
1141 })
1142 })
1143 .into_iter()
1144 .flatten()
1145 }
1146 let rel_path = rel_path.join(paths::local_settings_file_relative_path());
1147 let project = all_projects(cx).find(|project| {
1148 project.read_with(cx, |project, cx| {
1149 project.contains_local_settings_file(worktree_id, &rel_path, cx)
1150 })
1151 });
1152 let Some(project) = project else {
1153 anyhow::bail!(
1154 "Could not find worktree containing settings file: {}",
1155 &rel_path.display(PathStyle::local())
1156 );
1157 };
1158 project.update(cx, |project, cx| {
1159 project.update_local_settings_file(worktree_id, rel_path, cx, update);
1160 });
1161 return Ok(());
1162 }
1163 SettingsUiFile::User => {
1164 // todo(settings_ui) error?
1165 SettingsStore::global(cx).update_settings_file(<dyn fs::Fs>::global(cx), update);
1166 Ok(())
1167 }
1168 SettingsUiFile::Server(_) => unimplemented!(),
1169 }
1170}
1171
1172fn render_text_field<T: From<String> + Into<String> + AsRef<str> + Clone>(
1173 field: SettingField<T>,
1174 file: SettingsUiFile,
1175 metadata: Option<&SettingsFieldMetadata>,
1176 cx: &mut App,
1177) -> AnyElement {
1178 let (_, initial_text) =
1179 SettingsStore::global(cx).get_value_from_file(file.to_settings(), field.pick);
1180 let initial_text = Some(initial_text.clone()).filter(|s| !s.as_ref().is_empty());
1181
1182 SettingsEditor::new()
1183 .when_some(initial_text, |editor, text| {
1184 editor.with_initial_text(text.into())
1185 })
1186 .when_some(
1187 metadata.and_then(|metadata| metadata.placeholder),
1188 |editor, placeholder| editor.with_placeholder(placeholder),
1189 )
1190 .on_confirm({
1191 move |new_text, cx| {
1192 update_settings_file(file.clone(), cx, move |settings, _cx| {
1193 *(field.pick_mut)(settings) = new_text.map(Into::into);
1194 })
1195 .log_err(); // todo(settings_ui) don't log err
1196 }
1197 })
1198 .into_any_element()
1199}
1200
1201fn render_toggle_button<B: Into<bool> + From<bool> + Copy>(
1202 field: SettingField<B>,
1203 file: SettingsUiFile,
1204 cx: &mut App,
1205) -> AnyElement {
1206 let (_, &value) = SettingsStore::global(cx).get_value_from_file(file.to_settings(), field.pick);
1207
1208 let toggle_state = if value.into() {
1209 ToggleState::Selected
1210 } else {
1211 ToggleState::Unselected
1212 };
1213
1214 Switch::new("toggle_button", toggle_state)
1215 .color(ui::SwitchColor::Accent)
1216 .on_click({
1217 move |state, _window, cx| {
1218 let state = *state == ui::ToggleState::Selected;
1219 update_settings_file(file.clone(), cx, move |settings, _cx| {
1220 *(field.pick_mut)(settings) = Some(state.into());
1221 })
1222 .log_err(); // todo(settings_ui) don't log err
1223 }
1224 })
1225 .color(SwitchColor::Accent)
1226 .into_any_element()
1227}
1228
1229fn render_font_picker(
1230 field: SettingField<settings::FontFamilyName>,
1231 file: SettingsUiFile,
1232 window: &mut Window,
1233 cx: &mut App,
1234) -> AnyElement {
1235 let current_value = SettingsStore::global(cx)
1236 .get_value_from_file(file.to_settings(), field.pick)
1237 .1
1238 .clone();
1239
1240 let font_picker = cx.new(|cx| {
1241 ui_input::font_picker(
1242 current_value.clone().into(),
1243 move |font_name, cx| {
1244 update_settings_file(file.clone(), cx, move |settings, _cx| {
1245 *(field.pick_mut)(settings) = Some(font_name.into());
1246 })
1247 .log_err(); // todo(settings_ui) don't log err
1248 },
1249 window,
1250 cx,
1251 )
1252 });
1253
1254 div()
1255 .child(
1256 PopoverMenu::new("font-picker")
1257 .menu(move |_window, _cx| Some(font_picker.clone()))
1258 .trigger(
1259 ButtonLike::new("font-family-button")
1260 .style(ButtonStyle::Outlined)
1261 .size(ButtonSize::Medium)
1262 .full_width()
1263 .child(
1264 h_flex()
1265 .w_full()
1266 .justify_between()
1267 .child(Label::new(current_value))
1268 .child(
1269 Icon::new(IconName::ChevronUpDown)
1270 .color(Color::Muted)
1271 .size(IconSize::XSmall),
1272 ),
1273 ),
1274 )
1275 .full_width(true)
1276 .anchor(gpui::Corner::TopLeft)
1277 .offset(gpui::Point {
1278 x: px(0.0),
1279 y: px(4.0),
1280 })
1281 .with_handle(ui::PopoverMenuHandle::default()),
1282 )
1283 .into_any_element()
1284}
1285
1286fn render_numeric_stepper<T: NumericStepperType + Send + Sync>(
1287 field: SettingField<T>,
1288 file: SettingsUiFile,
1289 window: &mut Window,
1290 cx: &mut App,
1291) -> AnyElement {
1292 let (_, &value) = SettingsStore::global(cx).get_value_from_file(file.to_settings(), field.pick);
1293
1294 NumericStepper::new("numeric_stepper", value, window, cx)
1295 .on_change({
1296 move |value, _window, cx| {
1297 let value = *value;
1298 update_settings_file(file.clone(), cx, move |settings, _cx| {
1299 *(field.pick_mut)(settings) = Some(value);
1300 })
1301 .log_err(); // todo(settings_ui) don't log err
1302 }
1303 })
1304 .style(NumericStepperStyle::Outlined)
1305 .into_any_element()
1306}
1307
1308fn render_dropdown<T>(
1309 field: SettingField<T>,
1310 file: SettingsUiFile,
1311 window: &mut Window,
1312 cx: &mut App,
1313) -> AnyElement
1314where
1315 T: strum::VariantArray + strum::VariantNames + Copy + PartialEq + Send + Sync + 'static,
1316{
1317 let variants = || -> &'static [T] { <T as strum::VariantArray>::VARIANTS };
1318 let labels = || -> &'static [&'static str] { <T as strum::VariantNames>::VARIANTS };
1319
1320 let (_, ¤t_value) =
1321 SettingsStore::global(cx).get_value_from_file(file.to_settings(), field.pick);
1322
1323 let current_value_label =
1324 labels()[variants().iter().position(|v| *v == current_value).unwrap()];
1325
1326 DropdownMenu::new(
1327 "dropdown",
1328 current_value_label,
1329 ContextMenu::build(window, cx, move |mut menu, _, _| {
1330 for (&value, &label) in std::iter::zip(variants(), labels()) {
1331 let file = file.clone();
1332 menu = menu.toggleable_entry(
1333 label,
1334 value == current_value,
1335 IconPosition::Start,
1336 None,
1337 move |_, cx| {
1338 if value == current_value {
1339 return;
1340 }
1341 update_settings_file(file.clone(), cx, move |settings, _cx| {
1342 *(field.pick_mut)(settings) = Some(value);
1343 })
1344 .log_err(); // todo(settings_ui) don't log err
1345 },
1346 );
1347 }
1348 menu
1349 }),
1350 )
1351 .trigger_size(ButtonSize::Medium)
1352 .style(DropdownStyle::Outlined)
1353 .offset(gpui::Point {
1354 x: px(0.0),
1355 y: px(2.0),
1356 })
1357 .into_any_element()
1358}
1359
1360#[cfg(test)]
1361mod test {
1362
1363 use super::*;
1364
1365 impl SettingsWindow {
1366 fn navbar_entry(&self) -> usize {
1367 self.navbar_entry
1368 }
1369
1370 fn new_builder(window: &mut Window, cx: &mut Context<Self>) -> Self {
1371 let mut this = Self::new(window, cx);
1372 this.navbar_entries.clear();
1373 this.pages.clear();
1374 this
1375 }
1376
1377 fn build(mut self) -> Self {
1378 self.build_search_matches();
1379 self.build_navbar();
1380 self
1381 }
1382
1383 fn add_page(
1384 mut self,
1385 title: &'static str,
1386 build_page: impl Fn(SettingsPage) -> SettingsPage,
1387 ) -> Self {
1388 let page = SettingsPage {
1389 title,
1390 items: Vec::default(),
1391 };
1392
1393 self.pages.push(build_page(page));
1394 self
1395 }
1396
1397 fn search(&mut self, search_query: &str, window: &mut Window, cx: &mut Context<Self>) {
1398 self.search_task.take();
1399 self.search_bar.update(cx, |editor, cx| {
1400 editor.set_text(search_query, window, cx);
1401 });
1402 self.update_matches(cx);
1403 }
1404
1405 fn assert_search_results(&self, other: &Self) {
1406 // page index could be different because of filtered out pages
1407 #[derive(Debug, PartialEq)]
1408 struct EntryMinimal {
1409 is_root: bool,
1410 title: &'static str,
1411 }
1412 pretty_assertions::assert_eq!(
1413 other
1414 .visible_navbar_entries()
1415 .map(|(_, entry)| EntryMinimal {
1416 is_root: entry.is_root,
1417 title: entry.title,
1418 })
1419 .collect::<Vec<_>>(),
1420 self.visible_navbar_entries()
1421 .map(|(_, entry)| EntryMinimal {
1422 is_root: entry.is_root,
1423 title: entry.title,
1424 })
1425 .collect::<Vec<_>>(),
1426 );
1427 assert_eq!(
1428 self.current_page().items.iter().collect::<Vec<_>>(),
1429 other.page_items().collect::<Vec<_>>()
1430 );
1431 }
1432 }
1433
1434 impl SettingsPage {
1435 fn item(mut self, item: SettingsPageItem) -> Self {
1436 self.items.push(item);
1437 self
1438 }
1439 }
1440
1441 impl SettingsPageItem {
1442 fn basic_item(title: &'static str, description: &'static str) -> Self {
1443 SettingsPageItem::SettingItem(SettingItem {
1444 title,
1445 description,
1446 field: Box::new(SettingField {
1447 pick: |settings_content| &settings_content.auto_update,
1448 pick_mut: |settings_content| &mut settings_content.auto_update,
1449 }),
1450 metadata: None,
1451 })
1452 }
1453 }
1454
1455 fn register_settings(cx: &mut App) {
1456 settings::init(cx);
1457 theme::init(theme::LoadThemes::JustBase, cx);
1458 workspace::init_settings(cx);
1459 project::Project::init_settings(cx);
1460 language::init(cx);
1461 editor::init(cx);
1462 menu::init();
1463 }
1464
1465 fn parse(input: &'static str, window: &mut Window, cx: &mut App) -> SettingsWindow {
1466 let mut pages: Vec<SettingsPage> = Vec::new();
1467 let mut expanded_pages = Vec::new();
1468 let mut selected_idx = None;
1469 let mut index = 0;
1470 let mut in_expanded_section = false;
1471
1472 for mut line in input
1473 .lines()
1474 .map(|line| line.trim())
1475 .filter(|line| !line.is_empty())
1476 {
1477 if let Some(pre) = line.strip_suffix('*') {
1478 assert!(selected_idx.is_none(), "Only one selected entry allowed");
1479 selected_idx = Some(index);
1480 line = pre;
1481 }
1482 let (kind, title) = line.split_once(" ").unwrap();
1483 assert_eq!(kind.len(), 1);
1484 let kind = kind.chars().next().unwrap();
1485 if kind == 'v' {
1486 let page_idx = pages.len();
1487 expanded_pages.push(page_idx);
1488 pages.push(SettingsPage {
1489 title,
1490 items: vec![],
1491 });
1492 index += 1;
1493 in_expanded_section = true;
1494 } else if kind == '>' {
1495 pages.push(SettingsPage {
1496 title,
1497 items: vec![],
1498 });
1499 index += 1;
1500 in_expanded_section = false;
1501 } else if kind == '-' {
1502 pages
1503 .last_mut()
1504 .unwrap()
1505 .items
1506 .push(SettingsPageItem::SectionHeader(title));
1507 if selected_idx == Some(index) && !in_expanded_section {
1508 panic!("Items in unexpanded sections cannot be selected");
1509 }
1510 index += 1;
1511 } else {
1512 panic!(
1513 "Entries must start with one of 'v', '>', or '-'\n line: {}",
1514 line
1515 );
1516 }
1517 }
1518
1519 let mut settings_window = SettingsWindow {
1520 files: Vec::default(),
1521 current_file: crate::SettingsUiFile::User,
1522 pages,
1523 search_bar: cx.new(|cx| Editor::single_line(window, cx)),
1524 navbar_entry: selected_idx.expect("Must have a selected navbar entry"),
1525 navbar_entries: Vec::default(),
1526 list_handle: UniformListScrollHandle::default(),
1527 search_matches: vec![],
1528 search_task: None,
1529 sub_page_stack: vec![],
1530 scroll_handle: ScrollHandle::new(),
1531 };
1532
1533 settings_window.build_search_matches();
1534 settings_window.build_navbar();
1535 for expanded_page_index in expanded_pages {
1536 for entry in &mut settings_window.navbar_entries {
1537 if entry.page_index == expanded_page_index && entry.is_root {
1538 entry.expanded = true;
1539 }
1540 }
1541 }
1542 settings_window
1543 }
1544
1545 #[track_caller]
1546 fn check_navbar_toggle(
1547 before: &'static str,
1548 toggle_page: &'static str,
1549 after: &'static str,
1550 window: &mut Window,
1551 cx: &mut App,
1552 ) {
1553 let mut settings_window = parse(before, window, cx);
1554 let toggle_page_idx = settings_window
1555 .pages
1556 .iter()
1557 .position(|page| page.title == toggle_page)
1558 .expect("page not found");
1559 let toggle_idx = settings_window
1560 .navbar_entries
1561 .iter()
1562 .position(|entry| entry.page_index == toggle_page_idx)
1563 .expect("page not found");
1564 settings_window.toggle_navbar_entry(toggle_idx);
1565
1566 let expected_settings_window = parse(after, window, cx);
1567
1568 pretty_assertions::assert_eq!(
1569 settings_window
1570 .visible_navbar_entries()
1571 .map(|(_, entry)| entry)
1572 .collect::<Vec<_>>(),
1573 expected_settings_window
1574 .visible_navbar_entries()
1575 .map(|(_, entry)| entry)
1576 .collect::<Vec<_>>(),
1577 );
1578 pretty_assertions::assert_eq!(
1579 settings_window.navbar_entries[settings_window.navbar_entry()],
1580 expected_settings_window.navbar_entries[expected_settings_window.navbar_entry()],
1581 );
1582 }
1583
1584 macro_rules! check_navbar_toggle {
1585 ($name:ident, before: $before:expr, toggle_page: $toggle_page:expr, after: $after:expr) => {
1586 #[gpui::test]
1587 fn $name(cx: &mut gpui::TestAppContext) {
1588 let window = cx.add_empty_window();
1589 window.update(|window, cx| {
1590 register_settings(cx);
1591 check_navbar_toggle($before, $toggle_page, $after, window, cx);
1592 });
1593 }
1594 };
1595 }
1596
1597 check_navbar_toggle!(
1598 navbar_basic_open,
1599 before: r"
1600 v General
1601 - General
1602 - Privacy*
1603 v Project
1604 - Project Settings
1605 ",
1606 toggle_page: "General",
1607 after: r"
1608 > General*
1609 v Project
1610 - Project Settings
1611 "
1612 );
1613
1614 check_navbar_toggle!(
1615 navbar_basic_close,
1616 before: r"
1617 > General*
1618 - General
1619 - Privacy
1620 v Project
1621 - Project Settings
1622 ",
1623 toggle_page: "General",
1624 after: r"
1625 v General*
1626 - General
1627 - Privacy
1628 v Project
1629 - Project Settings
1630 "
1631 );
1632
1633 check_navbar_toggle!(
1634 navbar_basic_second_root_entry_close,
1635 before: r"
1636 > General
1637 - General
1638 - Privacy
1639 v Project
1640 - Project Settings*
1641 ",
1642 toggle_page: "Project",
1643 after: r"
1644 > General
1645 > Project*
1646 "
1647 );
1648
1649 check_navbar_toggle!(
1650 navbar_toggle_subroot,
1651 before: r"
1652 v General Page
1653 - General
1654 - Privacy
1655 v Project
1656 - Worktree Settings Content*
1657 v AI
1658 - General
1659 > Appearance & Behavior
1660 ",
1661 toggle_page: "Project",
1662 after: r"
1663 v General Page
1664 - General
1665 - Privacy
1666 > Project*
1667 v AI
1668 - General
1669 > Appearance & Behavior
1670 "
1671 );
1672
1673 check_navbar_toggle!(
1674 navbar_toggle_close_propagates_selected_index,
1675 before: r"
1676 v General Page
1677 - General
1678 - Privacy
1679 v Project
1680 - Worktree Settings Content
1681 v AI
1682 - General*
1683 > Appearance & Behavior
1684 ",
1685 toggle_page: "General Page",
1686 after: r"
1687 > General Page
1688 v Project
1689 - Worktree Settings Content
1690 v AI
1691 - General*
1692 > Appearance & Behavior
1693 "
1694 );
1695
1696 check_navbar_toggle!(
1697 navbar_toggle_expand_propagates_selected_index,
1698 before: r"
1699 > General Page
1700 - General
1701 - Privacy
1702 v Project
1703 - Worktree Settings Content
1704 v AI
1705 - General*
1706 > Appearance & Behavior
1707 ",
1708 toggle_page: "General Page",
1709 after: r"
1710 v General Page
1711 - General
1712 - Privacy
1713 v Project
1714 - Worktree Settings Content
1715 v AI
1716 - General*
1717 > Appearance & Behavior
1718 "
1719 );
1720
1721 #[gpui::test]
1722 fn test_basic_search(cx: &mut gpui::TestAppContext) {
1723 let cx = cx.add_empty_window();
1724 let (actual, expected) = cx.update(|window, cx| {
1725 register_settings(cx);
1726
1727 let expected = cx.new(|cx| {
1728 SettingsWindow::new_builder(window, cx)
1729 .add_page("General", |page| {
1730 page.item(SettingsPageItem::SectionHeader("General settings"))
1731 .item(SettingsPageItem::basic_item("test title", "General test"))
1732 })
1733 .build()
1734 });
1735
1736 let actual = cx.new(|cx| {
1737 SettingsWindow::new_builder(window, cx)
1738 .add_page("General", |page| {
1739 page.item(SettingsPageItem::SectionHeader("General settings"))
1740 .item(SettingsPageItem::basic_item("test title", "General test"))
1741 })
1742 .add_page("Theme", |page| {
1743 page.item(SettingsPageItem::SectionHeader("Theme settings"))
1744 })
1745 .build()
1746 });
1747
1748 actual.update(cx, |settings, cx| settings.search("gen", window, cx));
1749
1750 (actual, expected)
1751 });
1752
1753 cx.cx.run_until_parked();
1754
1755 cx.update(|_window, cx| {
1756 let expected = expected.read(cx);
1757 let actual = actual.read(cx);
1758 expected.assert_search_results(&actual);
1759 })
1760 }
1761
1762 #[gpui::test]
1763 fn test_search_render_page_with_filtered_out_navbar_entries(cx: &mut gpui::TestAppContext) {
1764 let cx = cx.add_empty_window();
1765 let (actual, expected) = cx.update(|window, cx| {
1766 register_settings(cx);
1767
1768 let actual = cx.new(|cx| {
1769 SettingsWindow::new_builder(window, cx)
1770 .add_page("General", |page| {
1771 page.item(SettingsPageItem::SectionHeader("General settings"))
1772 .item(SettingsPageItem::basic_item(
1773 "Confirm Quit",
1774 "Whether to confirm before quitting Zed",
1775 ))
1776 .item(SettingsPageItem::basic_item(
1777 "Auto Update",
1778 "Automatically update Zed",
1779 ))
1780 })
1781 .add_page("AI", |page| {
1782 page.item(SettingsPageItem::basic_item(
1783 "Disable AI",
1784 "Whether to disable all AI features in Zed",
1785 ))
1786 })
1787 .add_page("Appearance & Behavior", |page| {
1788 page.item(SettingsPageItem::SectionHeader("Cursor")).item(
1789 SettingsPageItem::basic_item(
1790 "Cursor Shape",
1791 "Cursor shape for the editor",
1792 ),
1793 )
1794 })
1795 .build()
1796 });
1797
1798 let expected = cx.new(|cx| {
1799 SettingsWindow::new_builder(window, cx)
1800 .add_page("Appearance & Behavior", |page| {
1801 page.item(SettingsPageItem::SectionHeader("Cursor")).item(
1802 SettingsPageItem::basic_item(
1803 "Cursor Shape",
1804 "Cursor shape for the editor",
1805 ),
1806 )
1807 })
1808 .build()
1809 });
1810
1811 actual.update(cx, |settings, cx| settings.search("cursor", window, cx));
1812
1813 (actual, expected)
1814 });
1815
1816 cx.cx.run_until_parked();
1817
1818 cx.update(|_window, cx| {
1819 let expected = expected.read(cx);
1820 let actual = actual.read(cx);
1821 expected.assert_search_results(&actual);
1822 })
1823 }
1824}