1use fuzzy::{StringMatch, StringMatchCandidate, match_strings};
2use gpui::{
3 App, Context, DismissEvent, Entity, EventEmitter, Focusable, Render, Task, WeakEntity, Window,
4};
5use picker::{Picker, PickerDelegate};
6use settings::{ActiveSettingsProfileName, SettingsStore};
7use ui::{HighlightedLabel, ListItem, ListItemSpacing, prelude::*};
8use workspace::{ModalView, Workspace};
9
10pub fn init(cx: &mut App) {
11 cx.on_action(|_: &zed_actions::settings_profile_selector::Toggle, cx| {
12 workspace::with_active_or_new_workspace(cx, |workspace, window, cx| {
13 toggle_settings_profile_selector(workspace, window, cx);
14 });
15 });
16}
17
18fn toggle_settings_profile_selector(
19 workspace: &mut Workspace,
20 window: &mut Window,
21 cx: &mut Context<Workspace>,
22) {
23 workspace.toggle_modal(window, cx, |window, cx| {
24 let delegate = SettingsProfileSelectorDelegate::new(cx.entity().downgrade(), window, cx);
25 SettingsProfileSelector::new(delegate, window, cx)
26 });
27}
28
29pub struct SettingsProfileSelector {
30 picker: Entity<Picker<SettingsProfileSelectorDelegate>>,
31}
32
33impl ModalView for SettingsProfileSelector {}
34
35impl EventEmitter<DismissEvent> for SettingsProfileSelector {}
36
37impl Focusable for SettingsProfileSelector {
38 fn focus_handle(&self, cx: &App) -> gpui::FocusHandle {
39 self.picker.focus_handle(cx)
40 }
41}
42
43impl Render for SettingsProfileSelector {
44 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
45 v_flex().w(rems(22.)).child(self.picker.clone())
46 }
47}
48
49impl SettingsProfileSelector {
50 pub fn new(
51 delegate: SettingsProfileSelectorDelegate,
52 window: &mut Window,
53 cx: &mut Context<Self>,
54 ) -> Self {
55 let picker = cx.new(|cx| Picker::uniform_list(delegate, window, cx));
56 Self { picker }
57 }
58}
59
60pub struct SettingsProfileSelectorDelegate {
61 matches: Vec<StringMatch>,
62 profile_names: Vec<Option<String>>,
63 original_profile_name: Option<String>,
64 selected_profile_name: Option<String>,
65 selected_index: usize,
66 selection_completed: bool,
67 selector: WeakEntity<SettingsProfileSelector>,
68}
69
70impl SettingsProfileSelectorDelegate {
71 fn new(
72 selector: WeakEntity<SettingsProfileSelector>,
73 _: &mut Window,
74 cx: &mut Context<SettingsProfileSelector>,
75 ) -> Self {
76 let settings_store = cx.global::<SettingsStore>();
77 let mut profile_names: Vec<Option<String>> = settings_store
78 .configured_settings_profiles()
79 .map(|s| Some(s.to_string()))
80 .collect();
81 profile_names.insert(0, None);
82
83 let matches = profile_names
84 .iter()
85 .enumerate()
86 .map(|(ix, profile_name)| StringMatch {
87 candidate_id: ix,
88 score: 0.0,
89 positions: Default::default(),
90 string: display_name(profile_name),
91 })
92 .collect();
93
94 let profile_name = cx
95 .try_global::<ActiveSettingsProfileName>()
96 .map(|p| p.0.clone());
97
98 let mut this = Self {
99 matches,
100 profile_names,
101 original_profile_name: profile_name.clone(),
102 selected_profile_name: None,
103 selected_index: 0,
104 selection_completed: false,
105 selector,
106 };
107
108 if let Some(profile_name) = profile_name {
109 this.select_if_matching(&profile_name);
110 }
111
112 this
113 }
114
115 fn select_if_matching(&mut self, profile_name: &str) {
116 self.selected_index = self
117 .matches
118 .iter()
119 .position(|mat| mat.string == profile_name)
120 .unwrap_or(self.selected_index);
121 }
122
123 fn set_selected_profile(
124 &self,
125 cx: &mut Context<Picker<SettingsProfileSelectorDelegate>>,
126 ) -> Option<String> {
127 let mat = self.matches.get(self.selected_index)?;
128 let profile_name = self.profile_names.get(mat.candidate_id)?;
129 Self::update_active_profile_name_global(profile_name.clone(), cx)
130 }
131
132 fn update_active_profile_name_global(
133 profile_name: Option<String>,
134 cx: &mut Context<Picker<SettingsProfileSelectorDelegate>>,
135 ) -> Option<String> {
136 if let Some(profile_name) = profile_name {
137 cx.set_global(ActiveSettingsProfileName(profile_name.clone()));
138 return Some(profile_name);
139 }
140
141 if cx.has_global::<ActiveSettingsProfileName>() {
142 cx.remove_global::<ActiveSettingsProfileName>();
143 }
144
145 None
146 }
147}
148
149impl PickerDelegate for SettingsProfileSelectorDelegate {
150 type ListItem = ListItem;
151
152 fn placeholder_text(&self, _: &mut Window, _: &mut App) -> std::sync::Arc<str> {
153 "Select a settings profile...".into()
154 }
155
156 fn match_count(&self) -> usize {
157 self.matches.len()
158 }
159
160 fn selected_index(&self) -> usize {
161 self.selected_index
162 }
163
164 fn set_selected_index(
165 &mut self,
166 ix: usize,
167 _: &mut Window,
168 cx: &mut Context<Picker<SettingsProfileSelectorDelegate>>,
169 ) {
170 self.selected_index = ix;
171 self.selected_profile_name = self.set_selected_profile(cx);
172 }
173
174 fn update_matches(
175 &mut self,
176 query: String,
177 window: &mut Window,
178 cx: &mut Context<Picker<SettingsProfileSelectorDelegate>>,
179 ) -> Task<()> {
180 let background = cx.background_executor().clone();
181 let candidates = self
182 .profile_names
183 .iter()
184 .enumerate()
185 .map(|(id, profile_name)| StringMatchCandidate::new(id, &display_name(profile_name)))
186 .collect::<Vec<_>>();
187
188 cx.spawn_in(window, async move |this, cx| {
189 let matches = if query.is_empty() {
190 candidates
191 .into_iter()
192 .enumerate()
193 .map(|(index, candidate)| StringMatch {
194 candidate_id: index,
195 string: candidate.string,
196 positions: Vec::new(),
197 score: 0.0,
198 })
199 .collect()
200 } else {
201 match_strings(
202 &candidates,
203 &query,
204 false,
205 true,
206 100,
207 &Default::default(),
208 background,
209 )
210 .await
211 };
212
213 this.update_in(cx, |this, _, cx| {
214 this.delegate.matches = matches;
215 this.delegate.selected_index = this
216 .delegate
217 .selected_index
218 .min(this.delegate.matches.len().saturating_sub(1));
219 this.delegate.selected_profile_name = this.delegate.set_selected_profile(cx);
220 })
221 .ok();
222 })
223 }
224
225 fn confirm(
226 &mut self,
227 _: bool,
228 _: &mut Window,
229 cx: &mut Context<Picker<SettingsProfileSelectorDelegate>>,
230 ) {
231 self.selection_completed = true;
232 self.selector
233 .update(cx, |_, cx| {
234 cx.emit(DismissEvent);
235 })
236 .ok();
237 }
238
239 fn dismissed(
240 &mut self,
241 _: &mut Window,
242 cx: &mut Context<Picker<SettingsProfileSelectorDelegate>>,
243 ) {
244 if !self.selection_completed {
245 SettingsProfileSelectorDelegate::update_active_profile_name_global(
246 self.original_profile_name.clone(),
247 cx,
248 );
249 }
250 self.selector.update(cx, |_, cx| cx.emit(DismissEvent)).ok();
251 }
252
253 fn render_match(
254 &self,
255 ix: usize,
256 selected: bool,
257 _: &mut Window,
258 _: &mut Context<Picker<Self>>,
259 ) -> Option<Self::ListItem> {
260 let mat = &self.matches.get(ix)?;
261 let profile_name = &self.profile_names.get(mat.candidate_id)?;
262
263 Some(
264 ListItem::new(ix)
265 .inset(true)
266 .spacing(ListItemSpacing::Sparse)
267 .toggle_state(selected)
268 .child(HighlightedLabel::new(
269 display_name(profile_name),
270 mat.positions.clone(),
271 )),
272 )
273 }
274}
275
276fn display_name(profile_name: &Option<String>) -> String {
277 profile_name.clone().unwrap_or("Disabled".into())
278}
279
280#[cfg(test)]
281mod tests {
282 use super::*;
283 use editor;
284 use gpui::{TestAppContext, UpdateGlobal, VisualTestContext};
285 use menu::{Cancel, Confirm, SelectNext, SelectPrevious};
286 use project::{FakeFs, Project};
287 use serde_json::json;
288 use settings::Settings;
289 use theme_settings::ThemeSettings;
290 use workspace::{self, AppState, MultiWorkspace};
291 use zed_actions::settings_profile_selector;
292
293 async fn init_test(
294 user_settings_json: serde_json::Value,
295 cx: &mut TestAppContext,
296 ) -> (Entity<Workspace>, &mut VisualTestContext) {
297 cx.update(|cx| {
298 let state = AppState::test(cx);
299 let settings_store = SettingsStore::test(cx);
300 cx.set_global(settings_store);
301 settings::init(cx);
302 theme_settings::init(theme::LoadThemes::JustBase, cx);
303 super::init(cx);
304 editor::init(cx);
305 state
306 });
307
308 cx.update(|cx| {
309 SettingsStore::update_global(cx, |store, cx| {
310 store
311 .set_user_settings(&user_settings_json.to_string(), cx)
312 .unwrap();
313 });
314 });
315
316 let fs = FakeFs::new(cx.executor());
317 let project = Project::test(fs, ["/test".as_ref()], cx).await;
318 let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project, window, cx));
319 let cx = VisualTestContext::from_window(*window, cx).into_mut();
320 let workspace = window
321 .read_with(cx, |mw, _| mw.workspace().clone())
322 .unwrap();
323
324 cx.update(|_, cx| {
325 assert!(!cx.has_global::<ActiveSettingsProfileName>());
326 });
327
328 (workspace, cx)
329 }
330
331 #[track_caller]
332 fn active_settings_profile_picker(
333 workspace: &Entity<Workspace>,
334 cx: &mut VisualTestContext,
335 ) -> Entity<Picker<SettingsProfileSelectorDelegate>> {
336 workspace.update(cx, |workspace, cx| {
337 workspace
338 .active_modal::<SettingsProfileSelector>(cx)
339 .expect("settings profile selector is not open")
340 .read(cx)
341 .picker
342 .clone()
343 })
344 }
345
346 #[gpui::test]
347 async fn test_settings_profile_selector_state(cx: &mut TestAppContext) {
348 let classroom_and_streaming_profile_name = "Classroom / Streaming".to_string();
349 let demo_videos_profile_name = "Demo Videos".to_string();
350
351 let user_settings_json = json!({
352 "buffer_font_size": 10.0,
353 "profiles": {
354 classroom_and_streaming_profile_name.clone(): {
355 "settings": {
356 "buffer_font_size": 20.0,
357 }
358 },
359 demo_videos_profile_name.clone(): {
360 "settings": {
361 "buffer_font_size": 15.0
362 }
363 }
364 }
365 });
366 let (workspace, cx) = init_test(user_settings_json, cx).await;
367
368 cx.dispatch_action(settings_profile_selector::Toggle);
369 let picker = active_settings_profile_picker(&workspace, cx);
370
371 picker.read_with(cx, |picker, cx| {
372 assert_eq!(picker.delegate.matches.len(), 3);
373 assert_eq!(picker.delegate.matches[0].string, display_name(&None));
374 assert_eq!(
375 picker.delegate.matches[1].string,
376 classroom_and_streaming_profile_name
377 );
378 assert_eq!(picker.delegate.matches[2].string, demo_videos_profile_name);
379 assert_eq!(picker.delegate.matches.get(3), None);
380
381 assert_eq!(picker.delegate.selected_index, 0);
382 assert_eq!(picker.delegate.selected_profile_name, None);
383
384 assert_eq!(cx.try_global::<ActiveSettingsProfileName>(), None);
385 assert_eq!(ThemeSettings::get_global(cx).buffer_font_size(cx), px(10.0));
386 });
387
388 cx.dispatch_action(Confirm);
389
390 cx.update(|_, cx| {
391 assert_eq!(cx.try_global::<ActiveSettingsProfileName>(), None);
392 });
393
394 cx.dispatch_action(settings_profile_selector::Toggle);
395 let picker = active_settings_profile_picker(&workspace, cx);
396 cx.dispatch_action(SelectNext);
397
398 picker.read_with(cx, |picker, cx| {
399 assert_eq!(picker.delegate.selected_index, 1);
400 assert_eq!(
401 picker.delegate.selected_profile_name,
402 Some(classroom_and_streaming_profile_name.clone())
403 );
404
405 assert_eq!(
406 cx.try_global::<ActiveSettingsProfileName>()
407 .map(|p| p.0.clone()),
408 Some(classroom_and_streaming_profile_name.clone())
409 );
410
411 assert_eq!(ThemeSettings::get_global(cx).buffer_font_size(cx), px(20.0));
412 });
413
414 cx.dispatch_action(Cancel);
415
416 cx.update(|_, cx| {
417 assert_eq!(cx.try_global::<ActiveSettingsProfileName>(), None);
418 assert_eq!(ThemeSettings::get_global(cx).buffer_font_size(cx), px(10.0));
419 });
420
421 cx.dispatch_action(settings_profile_selector::Toggle);
422 let picker = active_settings_profile_picker(&workspace, cx);
423
424 cx.dispatch_action(SelectNext);
425
426 picker.read_with(cx, |picker, cx| {
427 assert_eq!(picker.delegate.selected_index, 1);
428 assert_eq!(
429 picker.delegate.selected_profile_name,
430 Some(classroom_and_streaming_profile_name.clone())
431 );
432
433 assert_eq!(
434 cx.try_global::<ActiveSettingsProfileName>()
435 .map(|p| p.0.clone()),
436 Some(classroom_and_streaming_profile_name.clone())
437 );
438
439 assert_eq!(ThemeSettings::get_global(cx).buffer_font_size(cx), px(20.0));
440 });
441
442 cx.dispatch_action(SelectNext);
443
444 picker.read_with(cx, |picker, cx| {
445 assert_eq!(picker.delegate.selected_index, 2);
446 assert_eq!(
447 picker.delegate.selected_profile_name,
448 Some(demo_videos_profile_name.clone())
449 );
450
451 assert_eq!(
452 cx.try_global::<ActiveSettingsProfileName>()
453 .map(|p| p.0.clone()),
454 Some(demo_videos_profile_name.clone())
455 );
456
457 assert_eq!(ThemeSettings::get_global(cx).buffer_font_size(cx), px(15.0));
458 });
459
460 cx.dispatch_action(Confirm);
461
462 cx.update(|_, cx| {
463 assert_eq!(
464 cx.try_global::<ActiveSettingsProfileName>()
465 .map(|p| p.0.clone()),
466 Some(demo_videos_profile_name.clone())
467 );
468 assert_eq!(ThemeSettings::get_global(cx).buffer_font_size(cx), px(15.0));
469 });
470
471 cx.dispatch_action(settings_profile_selector::Toggle);
472 let picker = active_settings_profile_picker(&workspace, cx);
473
474 picker.read_with(cx, |picker, cx| {
475 assert_eq!(picker.delegate.selected_index, 2);
476 assert_eq!(
477 picker.delegate.selected_profile_name,
478 Some(demo_videos_profile_name.clone())
479 );
480
481 assert_eq!(
482 cx.try_global::<ActiveSettingsProfileName>()
483 .map(|p| p.0.clone()),
484 Some(demo_videos_profile_name.clone())
485 );
486 assert_eq!(ThemeSettings::get_global(cx).buffer_font_size(cx), px(15.0));
487 });
488
489 cx.dispatch_action(SelectPrevious);
490
491 picker.read_with(cx, |picker, cx| {
492 assert_eq!(picker.delegate.selected_index, 1);
493 assert_eq!(
494 picker.delegate.selected_profile_name,
495 Some(classroom_and_streaming_profile_name.clone())
496 );
497
498 assert_eq!(
499 cx.try_global::<ActiveSettingsProfileName>()
500 .map(|p| p.0.clone()),
501 Some(classroom_and_streaming_profile_name.clone())
502 );
503
504 assert_eq!(ThemeSettings::get_global(cx).buffer_font_size(cx), px(20.0));
505 });
506
507 cx.dispatch_action(Cancel);
508
509 cx.update(|_, cx| {
510 assert_eq!(
511 cx.try_global::<ActiveSettingsProfileName>()
512 .map(|p| p.0.clone()),
513 Some(demo_videos_profile_name.clone())
514 );
515
516 assert_eq!(ThemeSettings::get_global(cx).buffer_font_size(cx), px(15.0));
517 });
518
519 cx.dispatch_action(settings_profile_selector::Toggle);
520 let picker = active_settings_profile_picker(&workspace, cx);
521
522 picker.read_with(cx, |picker, cx| {
523 assert_eq!(picker.delegate.selected_index, 2);
524 assert_eq!(
525 picker.delegate.selected_profile_name,
526 Some(demo_videos_profile_name.clone())
527 );
528
529 assert_eq!(
530 cx.try_global::<ActiveSettingsProfileName>()
531 .map(|p| p.0.clone()),
532 Some(demo_videos_profile_name)
533 );
534
535 assert_eq!(ThemeSettings::get_global(cx).buffer_font_size(cx), px(15.0));
536 });
537
538 cx.dispatch_action(SelectPrevious);
539
540 picker.read_with(cx, |picker, cx| {
541 assert_eq!(picker.delegate.selected_index, 1);
542 assert_eq!(
543 picker.delegate.selected_profile_name,
544 Some(classroom_and_streaming_profile_name.clone())
545 );
546
547 assert_eq!(
548 cx.try_global::<ActiveSettingsProfileName>()
549 .map(|p| p.0.clone()),
550 Some(classroom_and_streaming_profile_name)
551 );
552
553 assert_eq!(ThemeSettings::get_global(cx).buffer_font_size(cx), px(20.0));
554 });
555
556 cx.dispatch_action(SelectPrevious);
557
558 picker.read_with(cx, |picker, cx| {
559 assert_eq!(picker.delegate.selected_index, 0);
560 assert_eq!(picker.delegate.selected_profile_name, None);
561
562 assert_eq!(
563 cx.try_global::<ActiveSettingsProfileName>()
564 .map(|p| p.0.clone()),
565 None
566 );
567
568 assert_eq!(ThemeSettings::get_global(cx).buffer_font_size(cx), px(10.0));
569 });
570
571 cx.dispatch_action(Confirm);
572
573 cx.update(|_, cx| {
574 assert_eq!(cx.try_global::<ActiveSettingsProfileName>(), None);
575 assert_eq!(ThemeSettings::get_global(cx).buffer_font_size(cx), px(10.0));
576 });
577 }
578
579 #[gpui::test]
580 async fn test_settings_profile_with_user_base(cx: &mut TestAppContext) {
581 let user_settings_json = json!({
582 "buffer_font_size": 10.0,
583 "profiles": {
584 "Explicit User": {
585 "base": "user",
586 "settings": {
587 "buffer_font_size": 20.0
588 }
589 },
590 "Implicit User": {
591 "settings": {
592 "buffer_font_size": 20.0
593 }
594 }
595 }
596 });
597 let (workspace, cx) = init_test(user_settings_json, cx).await;
598
599 // Select "Explicit User" (index 1) — profile applies on top of user settings.
600 cx.dispatch_action(settings_profile_selector::Toggle);
601 let picker = active_settings_profile_picker(&workspace, cx);
602 cx.dispatch_action(SelectNext);
603
604 picker.read_with(cx, |picker, cx| {
605 assert_eq!(
606 picker.delegate.selected_profile_name.as_deref(),
607 Some("Explicit User")
608 );
609 assert_eq!(ThemeSettings::get_global(cx).buffer_font_size(cx), px(20.0));
610 });
611
612 cx.dispatch_action(Confirm);
613
614 // Select "Implicit User" (index 2) — no base specified, same behavior.
615 cx.dispatch_action(settings_profile_selector::Toggle);
616 let picker = active_settings_profile_picker(&workspace, cx);
617 cx.dispatch_action(SelectNext);
618
619 picker.read_with(cx, |picker, cx| {
620 assert_eq!(
621 picker.delegate.selected_profile_name.as_deref(),
622 Some("Implicit User")
623 );
624 assert_eq!(ThemeSettings::get_global(cx).buffer_font_size(cx), px(20.0));
625 });
626
627 cx.dispatch_action(Confirm);
628 }
629
630 #[gpui::test]
631 async fn test_settings_profile_with_default_base(cx: &mut TestAppContext) {
632 let user_settings_json = json!({
633 "buffer_font_size": 10.0,
634 "profiles": {
635 "Clean Slate": {
636 "base": "default"
637 },
638 "Custom on Defaults": {
639 "base": "default",
640 "settings": {
641 "buffer_font_size": 30.0
642 }
643 }
644 }
645 });
646 let (workspace, cx) = init_test(user_settings_json, cx).await;
647
648 // User has buffer_font_size: 10, factory default is 15.
649 cx.update(|_, cx| {
650 assert_eq!(ThemeSettings::get_global(cx).buffer_font_size(cx), px(10.0));
651 });
652
653 // "Clean Slate" has base: "default" with no settings overrides,
654 // so we get the factory default (15), not the user's value (10).
655 cx.dispatch_action(settings_profile_selector::Toggle);
656 let picker = active_settings_profile_picker(&workspace, cx);
657 cx.dispatch_action(SelectNext);
658
659 picker.read_with(cx, |picker, cx| {
660 assert_eq!(
661 picker.delegate.selected_profile_name.as_deref(),
662 Some("Clean Slate")
663 );
664 assert_eq!(ThemeSettings::get_global(cx).buffer_font_size(cx), px(15.0));
665 });
666
667 // "Custom on Defaults" has base: "default" with buffer_font_size: 30,
668 // so the profile's override (30) applies on top of the factory default,
669 // not on top of the user's value (10).
670 cx.dispatch_action(SelectNext);
671
672 picker.read_with(cx, |picker, cx| {
673 assert_eq!(
674 picker.delegate.selected_profile_name.as_deref(),
675 Some("Custom on Defaults")
676 );
677 assert_eq!(ThemeSettings::get_global(cx).buffer_font_size(cx), px(30.0));
678 });
679
680 cx.dispatch_action(Confirm);
681
682 cx.update(|_, cx| {
683 assert_eq!(ThemeSettings::get_global(cx).buffer_font_size(cx), px(30.0));
684 });
685 }
686
687 #[gpui::test]
688 async fn test_settings_profile_selector_is_in_user_configuration_order(
689 cx: &mut TestAppContext,
690 ) {
691 // Must be unique names (HashMap)
692 let user_settings_json = json!({
693 "profiles": {
694 "z": { "settings": {} },
695 "e": { "settings": {} },
696 "d": { "settings": {} },
697 " ": { "settings": {} },
698 "r": { "settings": {} },
699 "u": { "settings": {} },
700 "l": { "settings": {} },
701 "3": { "settings": {} },
702 "s": { "settings": {} },
703 "!": { "settings": {} },
704 }
705 });
706 let (workspace, cx) = init_test(user_settings_json, cx).await;
707
708 cx.dispatch_action(settings_profile_selector::Toggle);
709 let picker = active_settings_profile_picker(&workspace, cx);
710
711 picker.read_with(cx, |picker, _| {
712 assert_eq!(picker.delegate.matches.len(), 11);
713 assert_eq!(picker.delegate.matches[0].string, display_name(&None));
714 assert_eq!(picker.delegate.matches[1].string, "z");
715 assert_eq!(picker.delegate.matches[2].string, "e");
716 assert_eq!(picker.delegate.matches[3].string, "d");
717 assert_eq!(picker.delegate.matches[4].string, " ");
718 assert_eq!(picker.delegate.matches[5].string, "r");
719 assert_eq!(picker.delegate.matches[6].string, "u");
720 assert_eq!(picker.delegate.matches[7].string, "l");
721 assert_eq!(picker.delegate.matches[8].string, "3");
722 assert_eq!(picker.delegate.matches[9].string, "s");
723 assert_eq!(picker.delegate.matches[10].string, "!");
724 });
725 }
726}