1//! # settings_ui
2mod components;
3use editor::{Editor, EditorEvent};
4use feature_flags::{FeatureFlag, FeatureFlagAppExt as _};
5use fuzzy::StringMatchCandidate;
6use gpui::{
7 App, AppContext as _, Context, Div, Entity, Global, IntoElement, ReadGlobal as _, Render, Task,
8 TitlebarOptions, UniformListScrollHandle, Window, WindowHandle, WindowOptions, actions, div,
9 point, px, size, uniform_list,
10};
11use project::WorktreeId;
12use settings::{CursorShape, SaturatingBool, SettingsContent, SettingsStore};
13use std::{
14 any::{Any, TypeId, type_name},
15 cell::RefCell,
16 collections::HashMap,
17 ops::Range,
18 rc::Rc,
19 sync::{Arc, atomic::AtomicBool},
20};
21use ui::{Divider, DropdownMenu, ListItem, Switch, prelude::*};
22use util::{paths::PathStyle, rel_path::RelPath};
23
24use crate::components::SettingsEditor;
25
26#[derive(Clone, Copy)]
27struct SettingField<T: 'static> {
28 pick: fn(&SettingsContent) -> &Option<T>,
29 pick_mut: fn(&mut SettingsContent) -> &mut Option<T>,
30}
31
32trait AnySettingField {
33 fn as_any(&self) -> &dyn Any;
34 fn type_name(&self) -> &'static str;
35 fn type_id(&self) -> TypeId;
36 fn file_set_in(&self, file: SettingsUiFile, cx: &App) -> settings::SettingsFile;
37}
38
39impl<T> AnySettingField for SettingField<T> {
40 fn as_any(&self) -> &dyn Any {
41 self
42 }
43
44 fn type_name(&self) -> &'static str {
45 type_name::<T>()
46 }
47
48 fn type_id(&self) -> TypeId {
49 TypeId::of::<T>()
50 }
51
52 fn file_set_in(&self, file: SettingsUiFile, cx: &App) -> settings::SettingsFile {
53 let (file, _) = cx
54 .global::<SettingsStore>()
55 .get_value_from_file(file.to_settings(), self.pick);
56 return file;
57 }
58}
59
60#[derive(Default, Clone)]
61struct SettingFieldRenderer {
62 renderers: Rc<
63 RefCell<
64 HashMap<
65 TypeId,
66 Box<
67 dyn Fn(
68 &dyn AnySettingField,
69 SettingsUiFile,
70 Option<&SettingsFieldMetadata>,
71 &mut Window,
72 &mut App,
73 ) -> AnyElement,
74 >,
75 >,
76 >,
77 >,
78}
79
80impl Global for SettingFieldRenderer {}
81
82impl SettingFieldRenderer {
83 fn add_renderer<T: 'static>(
84 &mut self,
85 renderer: impl Fn(
86 &SettingField<T>,
87 SettingsUiFile,
88 Option<&SettingsFieldMetadata>,
89 &mut Window,
90 &mut App,
91 ) -> AnyElement
92 + 'static,
93 ) -> &mut Self {
94 let key = TypeId::of::<T>();
95 let renderer = Box::new(
96 move |any_setting_field: &dyn AnySettingField,
97 settings_file: SettingsUiFile,
98 metadata: Option<&SettingsFieldMetadata>,
99 window: &mut Window,
100 cx: &mut App| {
101 let field = any_setting_field
102 .as_any()
103 .downcast_ref::<SettingField<T>>()
104 .unwrap();
105 renderer(field, settings_file, metadata, window, cx)
106 },
107 );
108 self.renderers.borrow_mut().insert(key, renderer);
109 self
110 }
111
112 fn render(
113 &self,
114 any_setting_field: &dyn AnySettingField,
115 settings_file: SettingsUiFile,
116 metadata: Option<&SettingsFieldMetadata>,
117 window: &mut Window,
118 cx: &mut App,
119 ) -> AnyElement {
120 let key = any_setting_field.type_id();
121 if let Some(renderer) = self.renderers.borrow().get(&key) {
122 renderer(any_setting_field, settings_file, metadata, window, cx)
123 } else {
124 panic!(
125 "No renderer found for type: {}",
126 any_setting_field.type_name()
127 )
128 }
129 }
130}
131
132struct SettingsFieldMetadata {
133 placeholder: Option<&'static str>,
134}
135
136fn user_settings_data() -> Vec<SettingsPage> {
137 vec![
138 SettingsPage {
139 title: "General Page",
140 expanded: true,
141 items: vec![
142 SettingsPageItem::SectionHeader("General"),
143 SettingsPageItem::SettingItem(SettingItem {
144 title: "Confirm Quit",
145 description: "Whether to confirm before quitting Zed",
146 field: Box::new(SettingField {
147 pick: |settings_content| &settings_content.workspace.confirm_quit,
148 pick_mut: |settings_content| &mut settings_content.workspace.confirm_quit,
149 }),
150 metadata: None,
151 }),
152 SettingsPageItem::SettingItem(SettingItem {
153 title: "Auto Update",
154 description: "Automatically update Zed (may be ignored on Linux if installed through a package manager)",
155 field: Box::new(SettingField {
156 pick: |settings_content| &settings_content.auto_update,
157 pick_mut: |settings_content| &mut settings_content.auto_update,
158 }),
159 metadata: None,
160 }),
161 SettingsPageItem::SectionHeader("Privacy"),
162 ],
163 },
164 SettingsPage {
165 title: "Project",
166 expanded: true,
167 items: vec![
168 SettingsPageItem::SectionHeader("Worktree Settings Content"),
169 SettingsPageItem::SettingItem(SettingItem {
170 title: "Project Name",
171 description: "The displayed name of this project. If not set, the root directory name",
172 field: Box::new(SettingField {
173 pick: |settings_content| &settings_content.project.worktree.project_name,
174 pick_mut: |settings_content| {
175 &mut settings_content.project.worktree.project_name
176 },
177 }),
178 metadata: Some(Box::new(SettingsFieldMetadata {
179 placeholder: Some("A new name"),
180 })),
181 }),
182 ],
183 },
184 SettingsPage {
185 title: "AI",
186 expanded: true,
187 items: vec![
188 SettingsPageItem::SectionHeader("General"),
189 SettingsPageItem::SettingItem(SettingItem {
190 title: "Disable AI",
191 description: "Whether to disable all AI features in Zed",
192 field: Box::new(SettingField {
193 pick: |settings_content| &settings_content.disable_ai,
194 pick_mut: |settings_content| &mut settings_content.disable_ai,
195 }),
196 metadata: None,
197 }),
198 ],
199 },
200 SettingsPage {
201 title: "Appearance & Behavior",
202 expanded: true,
203 items: vec![
204 SettingsPageItem::SectionHeader("Cursor"),
205 SettingsPageItem::SettingItem(SettingItem {
206 title: "Cursor Shape",
207 description: "Cursor shape for the editor",
208 field: Box::new(SettingField {
209 pick: |settings_content| &settings_content.editor.cursor_shape,
210 pick_mut: |settings_content| &mut settings_content.editor.cursor_shape,
211 }),
212 metadata: None,
213 }),
214 ],
215 },
216 ]
217}
218
219// Derive Macro, on the new ProjectSettings struct
220
221fn project_settings_data() -> Vec<SettingsPage> {
222 vec![SettingsPage {
223 title: "Project",
224 expanded: true,
225 items: vec![
226 SettingsPageItem::SectionHeader("Worktree Settings Content"),
227 SettingsPageItem::SettingItem(SettingItem {
228 title: "Project Name",
229 description: "The displayed name of this project. If not set, the root directory name",
230 field: Box::new(SettingField {
231 pick: |settings_content| &settings_content.project.worktree.project_name,
232 pick_mut: |settings_content| {
233 &mut settings_content.project.worktree.project_name
234 },
235 }),
236 metadata: Some(Box::new(SettingsFieldMetadata {
237 placeholder: Some("A new name"),
238 })),
239 }),
240 ],
241 }]
242}
243
244pub struct SettingsUiFeatureFlag;
245
246impl FeatureFlag for SettingsUiFeatureFlag {
247 const NAME: &'static str = "settings-ui";
248}
249
250actions!(
251 zed,
252 [
253 /// Opens Settings Editor.
254 OpenSettingsEditor
255 ]
256);
257
258pub fn init(cx: &mut App) {
259 init_renderers(cx);
260
261 cx.observe_new(|workspace: &mut workspace::Workspace, _, _| {
262 workspace.register_action_renderer(|div, _, _, cx| {
263 let settings_ui_actions = [std::any::TypeId::of::<OpenSettingsEditor>()];
264 let has_flag = cx.has_flag::<SettingsUiFeatureFlag>();
265 command_palette_hooks::CommandPaletteFilter::update_global(cx, |filter, _| {
266 if has_flag {
267 filter.show_action_types(&settings_ui_actions);
268 } else {
269 filter.hide_action_types(&settings_ui_actions);
270 }
271 });
272 if has_flag {
273 div.on_action(cx.listener(|_, _: &OpenSettingsEditor, _, cx| {
274 open_settings_editor(cx).ok();
275 }))
276 } else {
277 div
278 }
279 });
280 })
281 .detach();
282}
283
284fn init_renderers(cx: &mut App) {
285 // fn (field: SettingsField, current_file: SettingsFile, cx) -> (currently_set_in: SettingsFile, overridden_in: Vec<SettingsFile>)
286 cx.default_global::<SettingFieldRenderer>()
287 .add_renderer::<bool>(|settings_field, file, _, _, cx| {
288 render_toggle_button(*settings_field, file, cx).into_any_element()
289 })
290 .add_renderer::<String>(|settings_field, file, metadata, _, cx| {
291 render_text_field(settings_field.clone(), file, metadata, cx)
292 })
293 .add_renderer::<SaturatingBool>(|settings_field, file, _, _, cx| {
294 render_toggle_button(*settings_field, file, cx)
295 })
296 .add_renderer::<CursorShape>(|settings_field, file, _, window, cx| {
297 render_dropdown(*settings_field, file, window, cx)
298 });
299}
300
301pub fn open_settings_editor(cx: &mut App) -> anyhow::Result<WindowHandle<SettingsWindow>> {
302 cx.open_window(
303 WindowOptions {
304 titlebar: Some(TitlebarOptions {
305 title: Some("Settings Window".into()),
306 appears_transparent: true,
307 traffic_light_position: Some(point(px(12.0), px(12.0))),
308 }),
309 focus: true,
310 show: true,
311 kind: gpui::WindowKind::Normal,
312 window_background: cx.theme().window_background_appearance(),
313 window_min_size: Some(size(px(800.), px(600.))), // 4:3 Aspect Ratio
314 ..Default::default()
315 },
316 |window, cx| cx.new(|cx| SettingsWindow::new(window, cx)),
317 )
318}
319
320pub struct SettingsWindow {
321 files: Vec<SettingsUiFile>,
322 current_file: SettingsUiFile,
323 pages: Vec<SettingsPage>,
324 search_bar: Entity<Editor>,
325 search_task: Option<Task<()>>,
326 navbar_entry: usize, // Index into pages - should probably be (usize, Option<usize>) for section + page
327 navbar_entries: Vec<NavBarEntry>,
328 list_handle: UniformListScrollHandle,
329 search_matches: Vec<Vec<bool>>,
330}
331
332#[derive(PartialEq, Debug)]
333struct NavBarEntry {
334 title: &'static str,
335 is_root: bool,
336 page_index: usize,
337}
338
339struct SettingsPage {
340 title: &'static str,
341 expanded: bool,
342 items: Vec<SettingsPageItem>,
343}
344
345#[derive(PartialEq)]
346enum SettingsPageItem {
347 SectionHeader(&'static str),
348 SettingItem(SettingItem),
349}
350
351impl std::fmt::Debug for SettingsPageItem {
352 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
353 match self {
354 SettingsPageItem::SectionHeader(header) => write!(f, "SectionHeader({})", header),
355 SettingsPageItem::SettingItem(setting_item) => {
356 write!(f, "SettingItem({})", setting_item.title)
357 }
358 }
359 }
360}
361
362impl SettingsPageItem {
363 fn render(&self, file: SettingsUiFile, window: &mut Window, cx: &mut App) -> AnyElement {
364 match self {
365 SettingsPageItem::SectionHeader(header) => v_flex()
366 .w_full()
367 .gap_0p5()
368 .child(Label::new(SharedString::new_static(header)).size(LabelSize::Large))
369 .child(Divider::horizontal().color(ui::DividerColor::BorderVariant))
370 .into_any_element(),
371 SettingsPageItem::SettingItem(setting_item) => {
372 let renderer = cx.default_global::<SettingFieldRenderer>().clone();
373 let file_set_in =
374 SettingsUiFile::from_settings(setting_item.field.file_set_in(file.clone(), cx));
375
376 h_flex()
377 .id(setting_item.title)
378 .w_full()
379 .gap_2()
380 .flex_wrap()
381 .justify_between()
382 .child(
383 v_flex()
384 .max_w_1_2()
385 .flex_shrink()
386 .child(
387 h_flex()
388 .w_full()
389 .gap_4()
390 .child(
391 Label::new(SharedString::new_static(setting_item.title))
392 .size(LabelSize::Default),
393 )
394 .when_some(
395 file_set_in.filter(|file_set_in| file_set_in != &file),
396 |elem, file_set_in| {
397 elem.child(
398 Label::new(format!(
399 "set in {}",
400 file_set_in.name()
401 ))
402 .color(Color::Muted),
403 )
404 },
405 ),
406 )
407 .child(
408 Label::new(SharedString::new_static(setting_item.description))
409 .size(LabelSize::Small)
410 .color(Color::Muted),
411 ),
412 )
413 .child(renderer.render(
414 setting_item.field.as_ref(),
415 file,
416 setting_item.metadata.as_deref(),
417 window,
418 cx,
419 ))
420 .into_any_element()
421 }
422 }
423 }
424}
425
426struct SettingItem {
427 title: &'static str,
428 description: &'static str,
429 field: Box<dyn AnySettingField>,
430 metadata: Option<Box<SettingsFieldMetadata>>,
431}
432
433impl PartialEq for SettingItem {
434 fn eq(&self, other: &Self) -> bool {
435 self.title == other.title
436 && self.description == other.description
437 && (match (&self.metadata, &other.metadata) {
438 (None, None) => true,
439 (Some(m1), Some(m2)) => m1.placeholder == m2.placeholder,
440 _ => false,
441 })
442 }
443}
444
445#[allow(unused)]
446#[derive(Clone, PartialEq)]
447enum SettingsUiFile {
448 User, // Uses all settings.
449 Local((WorktreeId, Arc<RelPath>)), // Has a special name, and special set of settings
450 Server(&'static str), // Uses a special name, and the user settings
451}
452
453impl SettingsUiFile {
454 fn pages(&self) -> Vec<SettingsPage> {
455 match self {
456 SettingsUiFile::User => user_settings_data(),
457 SettingsUiFile::Local(_) => project_settings_data(),
458 SettingsUiFile::Server(_) => user_settings_data(),
459 }
460 }
461
462 fn name(&self) -> SharedString {
463 match self {
464 SettingsUiFile::User => SharedString::new_static("User"),
465 // TODO is PathStyle::local() ever not appropriate?
466 SettingsUiFile::Local((_, path)) => {
467 format!("Local ({})", path.display(PathStyle::local())).into()
468 }
469 SettingsUiFile::Server(file) => format!("Server ({})", file).into(),
470 }
471 }
472
473 fn from_settings(file: settings::SettingsFile) -> Option<Self> {
474 Some(match file {
475 settings::SettingsFile::User => SettingsUiFile::User,
476 settings::SettingsFile::Local(location) => SettingsUiFile::Local(location),
477 settings::SettingsFile::Server => SettingsUiFile::Server("todo: server name"),
478 settings::SettingsFile::Default => return None,
479 })
480 }
481
482 fn to_settings(&self) -> settings::SettingsFile {
483 match self {
484 SettingsUiFile::User => settings::SettingsFile::User,
485 SettingsUiFile::Local(location) => settings::SettingsFile::Local(location.clone()),
486 SettingsUiFile::Server(_) => settings::SettingsFile::Server,
487 }
488 }
489}
490
491impl SettingsWindow {
492 pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
493 let current_file = SettingsUiFile::User;
494 let search_bar = cx.new(|cx| {
495 let mut editor = Editor::single_line(window, cx);
496 editor.set_placeholder_text("Search settings…", window, cx);
497 editor
498 });
499
500 cx.subscribe(&search_bar, |this, _, event: &EditorEvent, cx| {
501 let EditorEvent::Edited { transaction_id: _ } = event else {
502 return;
503 };
504
505 this.update_matches(cx);
506 })
507 .detach();
508
509 cx.observe_global_in::<SettingsStore>(window, move |this, _, cx| {
510 this.fetch_files(cx);
511 cx.notify();
512 })
513 .detach();
514
515 let mut this = Self {
516 files: vec![],
517 current_file: current_file,
518 pages: vec![],
519 navbar_entries: vec![],
520 navbar_entry: 0,
521 list_handle: UniformListScrollHandle::default(),
522 search_bar,
523 search_task: None,
524 search_matches: vec![],
525 };
526
527 this.fetch_files(cx);
528 this.build_ui(cx);
529
530 this
531 }
532
533 fn toggle_navbar_entry(&mut self, ix: usize) {
534 // We can only toggle root entries
535 if !self.navbar_entries[ix].is_root {
536 return;
537 }
538
539 let toggle_page_index = self.page_index_from_navbar_index(ix);
540 let selected_page_index = self.page_index_from_navbar_index(self.navbar_entry);
541
542 let expanded = &mut self.page_for_navbar_index(ix).expanded;
543 *expanded = !*expanded;
544 let expanded = *expanded;
545 // if currently selected page is a child of the parent page we are folding,
546 // set the current page to the parent page
547 if selected_page_index == toggle_page_index {
548 self.navbar_entry = ix;
549 } else if selected_page_index > toggle_page_index {
550 let sub_items_count = self.pages[toggle_page_index]
551 .items
552 .iter()
553 .filter(|item| matches!(item, SettingsPageItem::SectionHeader(_)))
554 .count();
555 if expanded {
556 self.navbar_entry += sub_items_count;
557 } else {
558 self.navbar_entry -= sub_items_count;
559 }
560 }
561
562 self.build_navbar();
563 }
564
565 fn build_navbar(&mut self) {
566 let mut navbar_entries = Vec::with_capacity(self.navbar_entries.len());
567 for (page_index, page) in self.pages.iter().enumerate() {
568 if !self.search_matches[page_index]
569 .iter()
570 .any(|is_match| *is_match)
571 && !self.search_matches[page_index].is_empty()
572 {
573 continue;
574 }
575 navbar_entries.push(NavBarEntry {
576 title: page.title,
577 is_root: true,
578 page_index,
579 });
580 if !page.expanded {
581 continue;
582 }
583
584 for (item_index, item) in page.items.iter().enumerate() {
585 let SettingsPageItem::SectionHeader(title) = item else {
586 continue;
587 };
588 if !self.search_matches[page_index][item_index] {
589 continue;
590 }
591
592 navbar_entries.push(NavBarEntry {
593 title,
594 is_root: false,
595 page_index,
596 });
597 }
598 }
599 self.navbar_entries = navbar_entries;
600 }
601
602 fn update_matches(&mut self, cx: &mut Context<SettingsWindow>) {
603 self.search_task.take();
604 let query = self.search_bar.read(cx).text(cx);
605 if query.is_empty() {
606 for page in &mut self.search_matches {
607 page.fill(true);
608 }
609 self.build_navbar();
610 cx.notify();
611 return;
612 }
613
614 struct ItemKey {
615 page_index: usize,
616 header_index: usize,
617 item_index: usize,
618 }
619 let mut key_lut: Vec<ItemKey> = vec![];
620 let mut candidates = Vec::default();
621
622 for (page_index, page) in self.pages.iter().enumerate() {
623 let mut header_index = 0;
624 for (item_index, item) in page.items.iter().enumerate() {
625 let key_index = key_lut.len();
626 match item {
627 SettingsPageItem::SettingItem(item) => {
628 candidates.push(StringMatchCandidate::new(key_index, item.title));
629 candidates.push(StringMatchCandidate::new(key_index, item.description));
630 }
631 SettingsPageItem::SectionHeader(header) => {
632 candidates.push(StringMatchCandidate::new(key_index, header));
633 header_index = item_index;
634 }
635 }
636 key_lut.push(ItemKey {
637 page_index,
638 header_index,
639 item_index,
640 });
641 }
642 }
643 let atomic_bool = AtomicBool::new(false);
644
645 self.search_task = Some(cx.spawn(async move |this, cx| {
646 let string_matches = fuzzy::match_strings(
647 candidates.as_slice(),
648 &query,
649 false,
650 false,
651 candidates.len(),
652 &atomic_bool,
653 cx.background_executor().clone(),
654 );
655 let string_matches = string_matches.await;
656
657 this.update(cx, |this, cx| {
658 for page in &mut this.search_matches {
659 page.fill(false);
660 }
661
662 for string_match in string_matches {
663 let ItemKey {
664 page_index,
665 header_index,
666 item_index,
667 } = key_lut[string_match.candidate_id];
668 let page = &mut this.search_matches[page_index];
669 page[header_index] = true;
670 page[item_index] = true;
671 }
672 this.build_navbar();
673 this.navbar_entry = 0;
674 cx.notify();
675 })
676 .ok();
677 }));
678 }
679
680 fn build_ui(&mut self, cx: &mut Context<SettingsWindow>) {
681 self.pages = self.current_file.pages();
682 self.search_matches = self
683 .pages
684 .iter()
685 .map(|page| vec![true; page.items.len()])
686 .collect::<Vec<_>>();
687 self.build_navbar();
688
689 if !self.search_bar.read(cx).is_empty(cx) {
690 self.update_matches(cx);
691 }
692
693 cx.notify();
694 }
695
696 fn fetch_files(&mut self, cx: &mut Context<SettingsWindow>) {
697 let settings_store = cx.global::<SettingsStore>();
698 let mut ui_files = vec![];
699 let all_files = settings_store.get_all_files();
700 for file in all_files {
701 let Some(settings_ui_file) = SettingsUiFile::from_settings(file) else {
702 continue;
703 };
704 ui_files.push(settings_ui_file);
705 }
706 ui_files.reverse();
707 self.files = ui_files;
708 if !self.files.contains(&self.current_file) {
709 self.change_file(0, cx);
710 }
711 }
712
713 fn change_file(&mut self, ix: usize, cx: &mut Context<SettingsWindow>) {
714 if ix >= self.files.len() {
715 self.current_file = SettingsUiFile::User;
716 return;
717 }
718 if self.files[ix] == self.current_file {
719 return;
720 }
721 self.current_file = self.files[ix].clone();
722 self.build_ui(cx);
723 }
724
725 fn render_files(&self, _window: &mut Window, cx: &mut Context<SettingsWindow>) -> Div {
726 h_flex()
727 .gap_1()
728 .children(self.files.iter().enumerate().map(|(ix, file)| {
729 Button::new(ix, file.name())
730 .on_click(cx.listener(move |this, _, _window, cx| this.change_file(ix, cx)))
731 }))
732 }
733
734 fn render_search(&self, _window: &mut Window, cx: &mut App) -> Div {
735 h_flex()
736 .pt_1()
737 .px_1p5()
738 .gap_1p5()
739 .rounded_sm()
740 .bg(cx.theme().colors().editor_background)
741 .border_1()
742 .border_color(cx.theme().colors().border)
743 .child(Icon::new(IconName::MagnifyingGlass).color(Color::Muted))
744 .child(self.search_bar.clone())
745 }
746
747 fn render_nav(&self, window: &mut Window, cx: &mut Context<SettingsWindow>) -> Div {
748 v_flex()
749 .w_64()
750 .p_2p5()
751 .pt_10()
752 .gap_3()
753 .flex_none()
754 .border_r_1()
755 .border_color(cx.theme().colors().border)
756 .bg(cx.theme().colors().panel_background)
757 .child(self.render_search(window, cx).pb_1())
758 .child(
759 uniform_list(
760 "settings-ui-nav-bar",
761 self.navbar_entries.len(),
762 cx.processor(|this, range: Range<usize>, _, cx| {
763 range
764 .into_iter()
765 .map(|ix| {
766 let entry = &this.navbar_entries[ix];
767
768 h_flex()
769 .id(("settings-ui-section", ix))
770 .w_full()
771 .pl_2p5()
772 .py_0p5()
773 .rounded_sm()
774 .border_1()
775 .border_color(cx.theme().colors().border_transparent)
776 .text_color(cx.theme().colors().text_muted)
777 .when(this.is_navbar_entry_selected(ix), |this| {
778 this.text_color(cx.theme().colors().text)
779 .bg(cx.theme().colors().element_selected.opacity(0.2))
780 .border_color(cx.theme().colors().border)
781 })
782 .child(
783 ListItem::new(("settings-ui-navbar-entry", ix))
784 .selectable(true)
785 .inset(true)
786 .indent_step_size(px(1.))
787 .indent_level(if entry.is_root { 1 } else { 3 })
788 .when(entry.is_root, |item| {
789 item.toggle(
790 this.pages
791 [this.page_index_from_navbar_index(ix)]
792 .expanded,
793 )
794 .always_show_disclosure_icon(true)
795 .on_toggle(cx.listener(move |this, _, _, cx| {
796 this.toggle_navbar_entry(ix);
797 cx.notify();
798 }))
799 })
800 .child(
801 h_flex()
802 .text_ui(cx)
803 .truncate()
804 .hover(|s| {
805 s.bg(cx.theme().colors().element_hover)
806 })
807 .child(entry.title),
808 ),
809 )
810 .on_click(cx.listener(move |this, _, _, cx| {
811 this.navbar_entry = ix;
812 cx.notify();
813 }))
814 })
815 .collect()
816 }),
817 )
818 .track_scroll(self.list_handle.clone())
819 .size_full()
820 .flex_grow(),
821 )
822 }
823
824 fn page_items(&self) -> impl Iterator<Item = &SettingsPageItem> {
825 let page_idx = self.current_page_index();
826
827 self.current_page()
828 .items
829 .iter()
830 .enumerate()
831 .filter_map(move |(item_index, item)| {
832 self.search_matches[page_idx][item_index].then_some(item)
833 })
834 }
835
836 fn render_page(&self, window: &mut Window, cx: &mut Context<SettingsWindow>) -> Div {
837 v_flex().gap_4().children(
838 self.page_items()
839 .map(|item| item.render(self.current_file.clone(), window, cx)),
840 )
841 }
842
843 fn current_page_index(&self) -> usize {
844 self.page_index_from_navbar_index(self.navbar_entry)
845 }
846
847 fn current_page(&self) -> &SettingsPage {
848 &self.pages[self.current_page_index()]
849 }
850
851 fn page_index_from_navbar_index(&self, index: usize) -> usize {
852 if self.navbar_entries.is_empty() {
853 return 0;
854 }
855
856 self.navbar_entries[index].page_index
857 }
858
859 fn page_for_navbar_index(&mut self, index: usize) -> &mut SettingsPage {
860 let index = self.page_index_from_navbar_index(index);
861 &mut self.pages[index]
862 }
863
864 fn is_navbar_entry_selected(&self, ix: usize) -> bool {
865 ix == self.navbar_entry
866 }
867}
868
869impl Render for SettingsWindow {
870 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
871 div()
872 .flex()
873 .flex_row()
874 .size_full()
875 .bg(cx.theme().colors().background)
876 .text_color(cx.theme().colors().text)
877 .child(self.render_nav(window, cx))
878 .child(
879 v_flex()
880 .w_full()
881 .pt_4()
882 .px_6()
883 .gap_4()
884 .bg(cx.theme().colors().editor_background)
885 .child(self.render_files(window, cx))
886 .child(self.render_page(window, cx)),
887 )
888 }
889}
890
891// fn read_field<T>(pick: fn(&SettingsContent) -> &Option<T>, file: SettingsFile, cx: &App) -> Option<T> {
892// let (_, value) = cx.global::<SettingsStore>().get_value_from_file(file.to_settings(), (), pick);
893// }
894
895fn render_text_field(
896 field: SettingField<String>,
897 file: SettingsUiFile,
898 metadata: Option<&SettingsFieldMetadata>,
899 cx: &mut App,
900) -> AnyElement {
901 let (_, initial_text) =
902 SettingsStore::global(cx).get_value_from_file(file.to_settings(), field.pick);
903 let initial_text = Some(initial_text.clone()).filter(|s| !s.is_empty());
904
905 SettingsEditor::new()
906 .when_some(initial_text, |editor, text| editor.with_initial_text(text))
907 .when_some(
908 metadata.and_then(|metadata| metadata.placeholder),
909 |editor, placeholder| editor.with_placeholder(placeholder),
910 )
911 .on_confirm(move |new_text, cx: &mut App| {
912 cx.update_global(move |store: &mut SettingsStore, cx| {
913 store.update_settings_file(<dyn fs::Fs>::global(cx), move |settings, _cx| {
914 *(field.pick_mut)(settings) = new_text;
915 });
916 });
917 })
918 .into_any_element()
919}
920
921fn render_toggle_button<B: Into<bool> + From<bool> + Copy>(
922 field: SettingField<B>,
923 file: SettingsUiFile,
924 cx: &mut App,
925) -> AnyElement {
926 let (_, &value) = SettingsStore::global(cx).get_value_from_file(file.to_settings(), field.pick);
927
928 let toggle_state = if value.into() {
929 ui::ToggleState::Selected
930 } else {
931 ui::ToggleState::Unselected
932 };
933
934 Switch::new("toggle_button", toggle_state)
935 .on_click({
936 move |state, _window, cx| {
937 let state = *state == ui::ToggleState::Selected;
938 let field = field;
939 cx.update_global(move |store: &mut SettingsStore, cx| {
940 store.update_settings_file(<dyn fs::Fs>::global(cx), move |settings, _cx| {
941 *(field.pick_mut)(settings) = Some(state.into());
942 });
943 });
944 }
945 })
946 .into_any_element()
947}
948
949fn render_dropdown<T>(
950 field: SettingField<T>,
951 file: SettingsUiFile,
952 window: &mut Window,
953 cx: &mut App,
954) -> AnyElement
955where
956 T: strum::VariantArray + strum::VariantNames + Copy + PartialEq + Send + 'static,
957{
958 let variants = || -> &'static [T] { <T as strum::VariantArray>::VARIANTS };
959 let labels = || -> &'static [&'static str] { <T as strum::VariantNames>::VARIANTS };
960
961 let (_, ¤t_value) =
962 SettingsStore::global(cx).get_value_from_file(file.to_settings(), field.pick);
963
964 let current_value_label =
965 labels()[variants().iter().position(|v| *v == current_value).unwrap()];
966
967 DropdownMenu::new(
968 "dropdown",
969 current_value_label,
970 ui::ContextMenu::build(window, cx, move |mut menu, _, _| {
971 for (value, label) in variants()
972 .into_iter()
973 .copied()
974 .zip(labels().into_iter().copied())
975 {
976 menu = menu.toggleable_entry(
977 label,
978 value == current_value,
979 ui::IconPosition::Start,
980 None,
981 move |_, cx| {
982 if value == current_value {
983 return;
984 }
985 cx.update_global(move |store: &mut SettingsStore, cx| {
986 store.update_settings_file(
987 <dyn fs::Fs>::global(cx),
988 move |settings, _cx| {
989 *(field.pick_mut)(settings) = Some(value);
990 },
991 );
992 });
993 },
994 );
995 }
996 menu
997 }),
998 )
999 .into_any_element()
1000}
1001
1002#[cfg(test)]
1003mod test {
1004
1005 use super::*;
1006
1007 impl SettingsWindow {
1008 fn navbar(&self) -> &[NavBarEntry] {
1009 self.navbar_entries.as_slice()
1010 }
1011
1012 fn navbar_entry(&self) -> usize {
1013 self.navbar_entry
1014 }
1015
1016 fn new_builder(window: &mut Window, cx: &mut Context<Self>) -> Self {
1017 let mut this = Self::new(window, cx);
1018 this.navbar_entries.clear();
1019 this.pages.clear();
1020 this
1021 }
1022
1023 fn build(mut self) -> Self {
1024 self.build_navbar();
1025 self
1026 }
1027
1028 fn add_page(
1029 mut self,
1030 title: &'static str,
1031 build_page: impl Fn(SettingsPage) -> SettingsPage,
1032 ) -> Self {
1033 let page = SettingsPage {
1034 title,
1035 expanded: false,
1036 items: Vec::default(),
1037 };
1038
1039 self.pages.push(build_page(page));
1040 self
1041 }
1042
1043 fn search(&mut self, search_query: &str, window: &mut Window, cx: &mut Context<Self>) {
1044 self.search_task.take();
1045 self.search_bar.update(cx, |editor, cx| {
1046 editor.set_text(search_query, window, cx);
1047 });
1048 self.update_matches(cx);
1049 }
1050
1051 fn assert_search_results(&self, other: &Self) {
1052 // page index could be different because of filtered out pages
1053 assert!(
1054 self.navbar_entries
1055 .iter()
1056 .zip(other.navbar_entries.iter())
1057 .all(|(entry, other)| {
1058 entry.is_root == other.is_root && entry.title == other.title
1059 })
1060 );
1061 assert_eq!(
1062 self.current_page().items.iter().collect::<Vec<_>>(),
1063 other.page_items().collect::<Vec<_>>()
1064 );
1065 }
1066 }
1067
1068 impl SettingsPage {
1069 fn item(mut self, item: SettingsPageItem) -> Self {
1070 self.items.push(item);
1071 self
1072 }
1073 }
1074
1075 impl SettingsPageItem {
1076 fn basic_item(title: &'static str, description: &'static str) -> Self {
1077 SettingsPageItem::SettingItem(SettingItem {
1078 title,
1079 description,
1080 field: Box::new(SettingField {
1081 pick: |settings_content| &settings_content.auto_update,
1082 pick_mut: |settings_content| &mut settings_content.auto_update,
1083 }),
1084 metadata: None,
1085 })
1086 }
1087 }
1088
1089 fn register_settings(cx: &mut App) {
1090 settings::init(cx);
1091 theme::init(theme::LoadThemes::JustBase, cx);
1092 workspace::init_settings(cx);
1093 project::Project::init_settings(cx);
1094 language::init(cx);
1095 editor::init(cx);
1096 menu::init();
1097 }
1098
1099 fn parse(input: &'static str, window: &mut Window, cx: &mut App) -> SettingsWindow {
1100 let mut pages: Vec<SettingsPage> = Vec::new();
1101 let mut current_page = None;
1102 let mut selected_idx = None;
1103 let mut ix = 0;
1104 let mut in_closed_subentry = false;
1105
1106 for mut line in input
1107 .lines()
1108 .map(|line| line.trim())
1109 .filter(|line| !line.is_empty())
1110 {
1111 let mut is_selected = false;
1112 if line.ends_with("*") {
1113 assert!(
1114 selected_idx.is_none(),
1115 "Can only have one selected navbar entry at a time"
1116 );
1117 selected_idx = Some(ix);
1118 line = &line[..line.len() - 1];
1119 is_selected = true;
1120 }
1121
1122 if line.starts_with("v") || line.starts_with(">") {
1123 if let Some(current_page) = current_page.take() {
1124 pages.push(current_page);
1125 }
1126
1127 let expanded = line.starts_with("v");
1128 in_closed_subentry = !expanded;
1129 ix += 1;
1130
1131 current_page = Some(SettingsPage {
1132 title: line.split_once(" ").unwrap().1,
1133 expanded,
1134 items: Vec::default(),
1135 });
1136 } else if line.starts_with("-") {
1137 if !in_closed_subentry {
1138 ix += 1;
1139 } else if is_selected && in_closed_subentry {
1140 panic!("Can't select sub entry if it's parent is closed");
1141 }
1142
1143 let Some(current_page) = current_page.as_mut() else {
1144 panic!("Sub entries must be within a page");
1145 };
1146
1147 current_page.items.push(SettingsPageItem::SectionHeader(
1148 line.split_once(" ").unwrap().1,
1149 ));
1150 } else {
1151 panic!(
1152 "Entries must start with one of 'v', '>', or '-'\n line: {}",
1153 line
1154 );
1155 }
1156 }
1157
1158 if let Some(current_page) = current_page.take() {
1159 pages.push(current_page);
1160 }
1161
1162 let search_matches = pages
1163 .iter()
1164 .map(|page| vec![true; page.items.len()])
1165 .collect::<Vec<_>>();
1166
1167 let mut settings_window = SettingsWindow {
1168 files: Vec::default(),
1169 current_file: crate::SettingsUiFile::User,
1170 pages,
1171 search_bar: cx.new(|cx| Editor::single_line(window, cx)),
1172 navbar_entry: selected_idx.expect("Must have a selected navbar entry"),
1173 navbar_entries: Vec::default(),
1174 list_handle: UniformListScrollHandle::default(),
1175 search_matches,
1176 search_task: None,
1177 };
1178
1179 settings_window.build_navbar();
1180 settings_window
1181 }
1182
1183 #[track_caller]
1184 fn check_navbar_toggle(
1185 before: &'static str,
1186 toggle_idx: usize,
1187 after: &'static str,
1188 window: &mut Window,
1189 cx: &mut App,
1190 ) {
1191 let mut settings_window = parse(before, window, cx);
1192 settings_window.toggle_navbar_entry(toggle_idx);
1193
1194 let expected_settings_window = parse(after, window, cx);
1195
1196 assert_eq!(settings_window.navbar(), expected_settings_window.navbar());
1197 assert_eq!(
1198 settings_window.navbar_entry(),
1199 expected_settings_window.navbar_entry()
1200 );
1201 }
1202
1203 macro_rules! check_navbar_toggle {
1204 ($name:ident, before: $before:expr, toggle_idx: $toggle_idx:expr, after: $after:expr) => {
1205 #[gpui::test]
1206 fn $name(cx: &mut gpui::TestAppContext) {
1207 let window = cx.add_empty_window();
1208 window.update(|window, cx| {
1209 register_settings(cx);
1210 check_navbar_toggle($before, $toggle_idx, $after, window, cx);
1211 });
1212 }
1213 };
1214 }
1215
1216 check_navbar_toggle!(
1217 navbar_basic_open,
1218 before: r"
1219 v General
1220 - General
1221 - Privacy*
1222 v Project
1223 - Project Settings
1224 ",
1225 toggle_idx: 0,
1226 after: r"
1227 > General*
1228 v Project
1229 - Project Settings
1230 "
1231 );
1232
1233 check_navbar_toggle!(
1234 navbar_basic_close,
1235 before: r"
1236 > General*
1237 - General
1238 - Privacy
1239 v Project
1240 - Project Settings
1241 ",
1242 toggle_idx: 0,
1243 after: r"
1244 v General*
1245 - General
1246 - Privacy
1247 v Project
1248 - Project Settings
1249 "
1250 );
1251
1252 check_navbar_toggle!(
1253 navbar_basic_second_root_entry_close,
1254 before: r"
1255 > General
1256 - General
1257 - Privacy
1258 v Project
1259 - Project Settings*
1260 ",
1261 toggle_idx: 1,
1262 after: r"
1263 > General
1264 > Project*
1265 "
1266 );
1267
1268 check_navbar_toggle!(
1269 navbar_toggle_subroot,
1270 before: r"
1271 v General Page
1272 - General
1273 - Privacy
1274 v Project
1275 - Worktree Settings Content*
1276 v AI
1277 - General
1278 > Appearance & Behavior
1279 ",
1280 toggle_idx: 3,
1281 after: r"
1282 v General Page
1283 - General
1284 - Privacy
1285 > Project*
1286 v AI
1287 - General
1288 > Appearance & Behavior
1289 "
1290 );
1291
1292 check_navbar_toggle!(
1293 navbar_toggle_close_propagates_selected_index,
1294 before: r"
1295 v General Page
1296 - General
1297 - Privacy
1298 v Project
1299 - Worktree Settings Content
1300 v AI
1301 - General*
1302 > Appearance & Behavior
1303 ",
1304 toggle_idx: 0,
1305 after: r"
1306 > General Page
1307 v Project
1308 - Worktree Settings Content
1309 v AI
1310 - General*
1311 > Appearance & Behavior
1312 "
1313 );
1314
1315 check_navbar_toggle!(
1316 navbar_toggle_expand_propagates_selected_index,
1317 before: r"
1318 > General Page
1319 - General
1320 - Privacy
1321 v Project
1322 - Worktree Settings Content
1323 v AI
1324 - General*
1325 > Appearance & Behavior
1326 ",
1327 toggle_idx: 0,
1328 after: r"
1329 v General Page
1330 - General
1331 - Privacy
1332 v Project
1333 - Worktree Settings Content
1334 v AI
1335 - General*
1336 > Appearance & Behavior
1337 "
1338 );
1339
1340 check_navbar_toggle!(
1341 navbar_toggle_sub_entry_does_nothing,
1342 before: r"
1343 > General Page
1344 - General
1345 - Privacy
1346 v Project
1347 - Worktree Settings Content
1348 v AI
1349 - General*
1350 > Appearance & Behavior
1351 ",
1352 toggle_idx: 4,
1353 after: r"
1354 > General Page
1355 - General
1356 - Privacy
1357 v Project
1358 - Worktree Settings Content
1359 v AI
1360 - General*
1361 > Appearance & Behavior
1362 "
1363 );
1364
1365 #[gpui::test]
1366 fn test_basic_search(cx: &mut gpui::TestAppContext) {
1367 let cx = cx.add_empty_window();
1368 let (actual, expected) = cx.update(|window, cx| {
1369 register_settings(cx);
1370
1371 let expected = cx.new(|cx| {
1372 SettingsWindow::new_builder(window, cx)
1373 .add_page("General", |page| {
1374 page.item(SettingsPageItem::SectionHeader("General settings"))
1375 .item(SettingsPageItem::basic_item("test title", "General test"))
1376 })
1377 .build()
1378 });
1379
1380 let actual = cx.new(|cx| {
1381 SettingsWindow::new_builder(window, cx)
1382 .add_page("General", |page| {
1383 page.item(SettingsPageItem::SectionHeader("General settings"))
1384 .item(SettingsPageItem::basic_item("test title", "General test"))
1385 })
1386 .add_page("Theme", |page| {
1387 page.item(SettingsPageItem::SectionHeader("Theme settings"))
1388 })
1389 .build()
1390 });
1391
1392 actual.update(cx, |settings, cx| settings.search("gen", window, cx));
1393
1394 (actual, expected)
1395 });
1396
1397 cx.cx.run_until_parked();
1398
1399 cx.update(|_window, cx| {
1400 let expected = expected.read(cx);
1401 let actual = actual.read(cx);
1402 expected.assert_search_results(&actual);
1403 })
1404 }
1405
1406 #[gpui::test]
1407 fn test_search_render_page_with_filtered_out_navbar_entries(cx: &mut gpui::TestAppContext) {
1408 let cx = cx.add_empty_window();
1409 let (actual, expected) = cx.update(|window, cx| {
1410 register_settings(cx);
1411
1412 let actual = cx.new(|cx| {
1413 SettingsWindow::new_builder(window, cx)
1414 .add_page("General", |page| {
1415 page.item(SettingsPageItem::SectionHeader("General settings"))
1416 .item(SettingsPageItem::basic_item(
1417 "Confirm Quit",
1418 "Whether to confirm before quitting Zed",
1419 ))
1420 .item(SettingsPageItem::basic_item(
1421 "Auto Update",
1422 "Automatically update Zed",
1423 ))
1424 })
1425 .add_page("AI", |page| {
1426 page.item(SettingsPageItem::basic_item(
1427 "Disable AI",
1428 "Whether to disable all AI features in Zed",
1429 ))
1430 })
1431 .add_page("Appearance & Behavior", |page| {
1432 page.item(SettingsPageItem::SectionHeader("Cursor")).item(
1433 SettingsPageItem::basic_item(
1434 "Cursor Shape",
1435 "Cursor shape for the editor",
1436 ),
1437 )
1438 })
1439 .build()
1440 });
1441
1442 let expected = cx.new(|cx| {
1443 SettingsWindow::new_builder(window, cx)
1444 .add_page("Appearance & Behavior", |page| {
1445 page.item(SettingsPageItem::SectionHeader("Cursor")).item(
1446 SettingsPageItem::basic_item(
1447 "Cursor Shape",
1448 "Cursor shape for the editor",
1449 ),
1450 )
1451 })
1452 .build()
1453 });
1454
1455 actual.update(cx, |settings, cx| settings.search("cursor", window, cx));
1456
1457 (actual, expected)
1458 });
1459
1460 cx.cx.run_until_parked();
1461
1462 cx.update(|_window, cx| {
1463 let expected = expected.read(cx);
1464 let actual = actual.read(cx);
1465 expected.assert_search_results(&actual);
1466 })
1467 }
1468}