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