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 Action, App, Div, Entity, FocusHandle, Focusable, FontWeight, Global, ReadGlobal as _,
11 ScrollHandle, Task, TitlebarOptions, UniformListScrollHandle, Window, WindowHandle,
12 WindowOptions, actions, div, point, prelude::*, px, size, uniform_list,
13};
14use heck::ToTitleCase as _;
15use project::WorktreeId;
16use schemars::JsonSchema;
17use serde::Deserialize;
18use settings::{
19 BottomDockLayout, CloseWindowWhenNoItems, CodeFade, CursorShape, OnLastWindowClosed,
20 RestoreOnStartupBehavior, SaturatingBool, SettingsContent, SettingsStore,
21};
22use std::{
23 any::{Any, TypeId, type_name},
24 cell::RefCell,
25 collections::HashMap,
26 num::{NonZero, NonZeroU32},
27 ops::Range,
28 rc::Rc,
29 sync::{Arc, LazyLock, RwLock, atomic::AtomicBool},
30};
31use ui::{
32 ContextMenu, Divider, DropdownMenu, DropdownStyle, IconButtonShape, KeyBinding, KeybindingHint,
33 PopoverMenu, Switch, SwitchColor, TreeViewItem, WithScrollbar, prelude::*,
34};
35use ui_input::{NumberField, NumberFieldType};
36use util::{ResultExt as _, paths::PathStyle, rel_path::RelPath};
37use zed_actions::OpenSettingsEditor;
38
39use crate::components::SettingsEditor;
40
41const NAVBAR_CONTAINER_TAB_INDEX: isize = 0;
42const NAVBAR_GROUP_TAB_INDEX: isize = 1;
43const CONTENT_CONTAINER_TAB_INDEX: isize = 2;
44const CONTENT_GROUP_TAB_INDEX: isize = 3;
45
46actions!(
47 settings_editor,
48 [
49 /// Minimizes the settings UI window.
50 Minimize,
51 /// Toggles focus between the navbar and the main content.
52 ToggleFocusNav,
53 /// Focuses the next file in the file list.
54 FocusNextFile,
55 /// Focuses the previous file in the file list.
56 FocusPreviousFile
57 ]
58);
59
60#[derive(Action, PartialEq, Eq, Clone, Copy, Debug, JsonSchema, Deserialize)]
61#[action(namespace = settings_editor)]
62struct FocusFile(pub u32);
63
64#[derive(Clone, Copy)]
65struct SettingField<T: 'static> {
66 pick: fn(&SettingsContent) -> &Option<T>,
67 pick_mut: fn(&mut SettingsContent) -> &mut Option<T>,
68}
69
70/// Helper for unimplemented settings, used in combination with `SettingField::unimplemented`
71/// to keep the setting around in the UI with valid pick and pick_mut implementations, but don't actually try to render it.
72/// TODO(settings_ui): In non-dev builds (`#[cfg(not(debug_assertions))]`) make this render as edit-in-json
73struct UnimplementedSettingField;
74
75impl<T: 'static> SettingField<T> {
76 /// Helper for settings with types that are not yet implemented.
77 #[allow(unused)]
78 fn unimplemented(self) -> SettingField<UnimplementedSettingField> {
79 SettingField {
80 pick: |_| &None,
81 pick_mut: |_| unreachable!(),
82 }
83 }
84}
85
86trait AnySettingField {
87 fn as_any(&self) -> &dyn Any;
88 fn type_name(&self) -> &'static str;
89 fn type_id(&self) -> TypeId;
90 // Returns the file this value was set in and true, or File::Default and false to indicate it was not found in any file (missing default)
91 fn file_set_in(&self, file: SettingsUiFile, cx: &App) -> (settings::SettingsFile, bool);
92}
93
94impl<T> AnySettingField for SettingField<T> {
95 fn as_any(&self) -> &dyn Any {
96 self
97 }
98
99 fn type_name(&self) -> &'static str {
100 type_name::<T>()
101 }
102
103 fn type_id(&self) -> TypeId {
104 TypeId::of::<T>()
105 }
106
107 fn file_set_in(&self, file: SettingsUiFile, cx: &App) -> (settings::SettingsFile, bool) {
108 if AnySettingField::type_id(self) == TypeId::of::<UnimplementedSettingField>() {
109 return (file.to_settings(), true);
110 }
111
112 let (file, value) = cx
113 .global::<SettingsStore>()
114 .get_value_from_file(file.to_settings(), self.pick);
115 return (file, value.is_some());
116 }
117}
118
119#[derive(Default, Clone)]
120struct SettingFieldRenderer {
121 renderers: Rc<
122 RefCell<
123 HashMap<
124 TypeId,
125 Box<
126 dyn Fn(
127 &dyn AnySettingField,
128 SettingsUiFile,
129 Option<&SettingsFieldMetadata>,
130 &mut Window,
131 &mut App,
132 ) -> AnyElement,
133 >,
134 >,
135 >,
136 >,
137}
138
139impl Global for SettingFieldRenderer {}
140
141impl SettingFieldRenderer {
142 fn add_renderer<T: 'static>(
143 &mut self,
144 renderer: impl Fn(
145 &SettingField<T>,
146 SettingsUiFile,
147 Option<&SettingsFieldMetadata>,
148 &mut Window,
149 &mut App,
150 ) -> AnyElement
151 + 'static,
152 ) -> &mut Self {
153 let key = TypeId::of::<T>();
154 let renderer = Box::new(
155 move |any_setting_field: &dyn AnySettingField,
156 settings_file: SettingsUiFile,
157 metadata: Option<&SettingsFieldMetadata>,
158 window: &mut Window,
159 cx: &mut App| {
160 let field = any_setting_field
161 .as_any()
162 .downcast_ref::<SettingField<T>>()
163 .unwrap();
164 renderer(field, settings_file, metadata, window, cx)
165 },
166 );
167 self.renderers.borrow_mut().insert(key, renderer);
168 self
169 }
170
171 fn render(
172 &self,
173 any_setting_field: &dyn AnySettingField,
174 settings_file: SettingsUiFile,
175 metadata: Option<&SettingsFieldMetadata>,
176 window: &mut Window,
177 cx: &mut App,
178 ) -> AnyElement {
179 let key = any_setting_field.type_id();
180 if let Some(renderer) = self.renderers.borrow().get(&key) {
181 renderer(any_setting_field, settings_file, metadata, window, cx)
182 } else {
183 panic!(
184 "No renderer found for type: {}",
185 any_setting_field.type_name()
186 )
187 }
188 }
189}
190
191struct SettingsFieldMetadata {
192 placeholder: Option<&'static str>,
193}
194
195pub struct SettingsUiFeatureFlag;
196
197impl FeatureFlag for SettingsUiFeatureFlag {
198 const NAME: &'static str = "settings-ui";
199}
200
201pub fn init(cx: &mut App) {
202 init_renderers(cx);
203
204 cx.observe_new(|workspace: &mut workspace::Workspace, _, _| {
205 workspace.register_action_renderer(|div, _, _, cx| {
206 let settings_ui_actions = [
207 TypeId::of::<OpenSettingsEditor>(),
208 TypeId::of::<ToggleFocusNav>(),
209 TypeId::of::<FocusFile>(),
210 TypeId::of::<FocusNextFile>(),
211 TypeId::of::<FocusPreviousFile>(),
212 ];
213 let has_flag = cx.has_flag::<SettingsUiFeatureFlag>();
214 command_palette_hooks::CommandPaletteFilter::update_global(cx, |filter, _| {
215 if has_flag {
216 filter.show_action_types(&settings_ui_actions);
217 } else {
218 filter.hide_action_types(&settings_ui_actions);
219 }
220 });
221 if has_flag {
222 div.on_action(cx.listener(|_, _: &OpenSettingsEditor, _, cx| {
223 open_settings_editor(cx).ok();
224 }))
225 } else {
226 div
227 }
228 });
229 })
230 .detach();
231}
232
233fn init_renderers(cx: &mut App) {
234 // fn (field: SettingsField, current_file: SettingsFile, cx) -> (currently_set_in: SettingsFile, overridden_in: Vec<SettingsFile>)
235 cx.default_global::<SettingFieldRenderer>()
236 .add_renderer::<UnimplementedSettingField>(|_, _, _, _, _| {
237 // TODO(settings_ui): In non-dev builds (`#[cfg(not(debug_assertions))]`) make this render as edit-in-json
238 Button::new("unimplemented-field", "UNIMPLEMENTED")
239 .size(ButtonSize::Medium)
240 .icon(IconName::XCircle)
241 .icon_position(IconPosition::Start)
242 .icon_color(Color::Error)
243 .icon_size(IconSize::Small)
244 .style(ButtonStyle::Outlined)
245 .into_any_element()
246 })
247 .add_renderer::<bool>(|settings_field, file, _, _, cx| {
248 render_toggle_button(*settings_field, file, cx).into_any_element()
249 })
250 .add_renderer::<String>(|settings_field, file, metadata, _, cx| {
251 render_text_field(settings_field.clone(), file, metadata, cx)
252 })
253 .add_renderer::<SaturatingBool>(|settings_field, file, _, _, cx| {
254 render_toggle_button(*settings_field, file, cx)
255 })
256 .add_renderer::<CursorShape>(|settings_field, file, _, window, cx| {
257 render_dropdown(*settings_field, file, window, cx)
258 })
259 .add_renderer::<RestoreOnStartupBehavior>(|settings_field, file, _, window, cx| {
260 render_dropdown(*settings_field, file, window, cx)
261 })
262 .add_renderer::<BottomDockLayout>(|settings_field, file, _, window, cx| {
263 render_dropdown(*settings_field, file, window, cx)
264 })
265 .add_renderer::<OnLastWindowClosed>(|settings_field, file, _, window, cx| {
266 render_dropdown(*settings_field, file, window, cx)
267 })
268 .add_renderer::<CloseWindowWhenNoItems>(|settings_field, file, _, window, cx| {
269 render_dropdown(*settings_field, file, window, cx)
270 })
271 .add_renderer::<settings::FontFamilyName>(|settings_field, file, _, window, cx| {
272 // todo(settings_ui): We need to pass in a validator for this to ensure that users that type in invalid font names
273 render_font_picker(settings_field.clone(), file, window, cx)
274 })
275 // todo(settings_ui): This needs custom ui
276 // .add_renderer::<settings::BufferLineHeight>(|settings_field, file, _, window, cx| {
277 // // todo(settings_ui): Do we want to expose the custom variant of buffer line height?
278 // // right now there's a manual impl of strum::VariantArray
279 // render_dropdown(*settings_field, file, window, cx)
280 // })
281 .add_renderer::<settings::BaseKeymapContent>(|settings_field, file, _, window, cx| {
282 render_dropdown(*settings_field, file, window, cx)
283 })
284 .add_renderer::<settings::MultiCursorModifier>(|settings_field, file, _, window, cx| {
285 render_dropdown(*settings_field, file, window, cx)
286 })
287 .add_renderer::<settings::HideMouseMode>(|settings_field, file, _, window, cx| {
288 render_dropdown(*settings_field, file, window, cx)
289 })
290 .add_renderer::<settings::CurrentLineHighlight>(|settings_field, file, _, window, cx| {
291 render_dropdown(*settings_field, file, window, cx)
292 })
293 .add_renderer::<settings::ShowWhitespaceSetting>(|settings_field, file, _, window, cx| {
294 render_dropdown(*settings_field, file, window, cx)
295 })
296 .add_renderer::<settings::SoftWrap>(|settings_field, file, _, window, cx| {
297 render_dropdown(*settings_field, file, window, cx)
298 })
299 .add_renderer::<settings::ScrollBeyondLastLine>(|settings_field, file, _, window, cx| {
300 render_dropdown(*settings_field, file, window, cx)
301 })
302 .add_renderer::<settings::SnippetSortOrder>(|settings_field, file, _, window, cx| {
303 render_dropdown(*settings_field, file, window, cx)
304 })
305 .add_renderer::<settings::ClosePosition>(|settings_field, file, _, window, cx| {
306 render_dropdown(*settings_field, file, window, cx)
307 })
308 .add_renderer::<settings::DockSide>(|settings_field, file, _, window, cx| {
309 render_dropdown(*settings_field, file, window, cx)
310 })
311 .add_renderer::<settings::TerminalDockPosition>(|settings_field, file, _, window, cx| {
312 render_dropdown(*settings_field, file, window, cx)
313 })
314 .add_renderer::<settings::DockPosition>(|settings_field, file, _, window, cx| {
315 render_dropdown(*settings_field, file, window, cx)
316 })
317 .add_renderer::<settings::GitGutterSetting>(|settings_field, file, _, window, cx| {
318 render_dropdown(*settings_field, file, window, cx)
319 })
320 .add_renderer::<settings::GitHunkStyleSetting>(|settings_field, file, _, window, cx| {
321 render_dropdown(*settings_field, file, window, cx)
322 })
323 .add_renderer::<settings::DiagnosticSeverityContent>(
324 |settings_field, file, _, window, cx| {
325 render_dropdown(*settings_field, file, window, cx)
326 },
327 )
328 .add_renderer::<settings::SeedQuerySetting>(|settings_field, file, _, window, cx| {
329 render_dropdown(*settings_field, file, window, cx)
330 })
331 .add_renderer::<settings::DoubleClickInMultibuffer>(
332 |settings_field, file, _, window, cx| {
333 render_dropdown(*settings_field, file, window, cx)
334 },
335 )
336 .add_renderer::<settings::GoToDefinitionFallback>(|settings_field, file, _, window, cx| {
337 render_dropdown(*settings_field, file, window, cx)
338 })
339 .add_renderer::<settings::ActivateOnClose>(|settings_field, file, _, window, cx| {
340 render_dropdown(*settings_field, file, window, cx)
341 })
342 .add_renderer::<settings::ShowDiagnostics>(|settings_field, file, _, window, cx| {
343 render_dropdown(*settings_field, file, window, cx)
344 })
345 .add_renderer::<settings::ShowCloseButton>(|settings_field, file, _, window, cx| {
346 render_dropdown(*settings_field, file, window, cx)
347 })
348 .add_renderer::<settings::ProjectPanelEntrySpacing>(
349 |settings_field, file, _, window, cx| {
350 render_dropdown(*settings_field, file, window, cx)
351 },
352 )
353 .add_renderer::<settings::RewrapBehavior>(|settings_field, file, _, window, cx| {
354 render_dropdown(*settings_field, file, window, cx)
355 })
356 .add_renderer::<settings::FormatOnSave>(|settings_field, file, _, window, cx| {
357 render_dropdown(*settings_field, file, window, cx)
358 })
359 .add_renderer::<settings::IndentGuideColoring>(|settings_field, file, _, window, cx| {
360 render_dropdown(*settings_field, file, window, cx)
361 })
362 .add_renderer::<settings::IndentGuideBackgroundColoring>(
363 |settings_field, file, _, window, cx| {
364 render_dropdown(*settings_field, file, window, cx)
365 },
366 )
367 .add_renderer::<settings::FileFinderWidthContent>(|settings_field, file, _, window, cx| {
368 render_dropdown(*settings_field, file, window, cx)
369 })
370 .add_renderer::<settings::ShowDiagnostics>(|settings_field, file, _, window, cx| {
371 render_dropdown(*settings_field, file, window, cx)
372 })
373 .add_renderer::<settings::WordsCompletionMode>(|settings_field, file, _, window, cx| {
374 render_dropdown(*settings_field, file, window, cx)
375 })
376 .add_renderer::<settings::LspInsertMode>(|settings_field, file, _, window, cx| {
377 render_dropdown(*settings_field, file, window, cx)
378 })
379 .add_renderer::<f32>(|settings_field, file, _, window, cx| {
380 render_number_field(*settings_field, file, window, cx)
381 })
382 .add_renderer::<u32>(|settings_field, file, _, window, cx| {
383 render_number_field(*settings_field, file, window, cx)
384 })
385 .add_renderer::<u64>(|settings_field, file, _, window, cx| {
386 render_number_field(*settings_field, file, window, cx)
387 })
388 .add_renderer::<usize>(|settings_field, file, _, window, cx| {
389 render_number_field(*settings_field, file, window, cx)
390 })
391 .add_renderer::<NonZero<usize>>(|settings_field, file, _, window, cx| {
392 render_number_field(*settings_field, file, window, cx)
393 })
394 .add_renderer::<NonZeroU32>(|settings_field, file, _, window, cx| {
395 render_number_field(*settings_field, file, window, cx)
396 })
397 .add_renderer::<CodeFade>(|settings_field, file, _, window, cx| {
398 render_number_field(*settings_field, file, window, cx)
399 })
400 .add_renderer::<FontWeight>(|settings_field, file, _, window, cx| {
401 render_number_field(*settings_field, file, window, cx)
402 })
403 .add_renderer::<settings::MinimumContrast>(|settings_field, file, _, window, cx| {
404 render_number_field(*settings_field, file, window, cx)
405 })
406 .add_renderer::<settings::ShowScrollbar>(|settings_field, file, _, window, cx| {
407 render_dropdown(*settings_field, file, window, cx)
408 })
409 .add_renderer::<settings::ScrollbarDiagnostics>(|settings_field, file, _, window, cx| {
410 render_dropdown(*settings_field, file, window, cx)
411 })
412 .add_renderer::<settings::ShowMinimap>(|settings_field, file, _, window, cx| {
413 render_dropdown(*settings_field, file, window, cx)
414 })
415 .add_renderer::<settings::DisplayIn>(|settings_field, file, _, window, cx| {
416 render_dropdown(*settings_field, file, window, cx)
417 })
418 .add_renderer::<settings::MinimapThumb>(|settings_field, file, _, window, cx| {
419 render_dropdown(*settings_field, file, window, cx)
420 })
421 .add_renderer::<settings::MinimapThumbBorder>(|settings_field, file, _, window, cx| {
422 render_dropdown(*settings_field, file, window, cx)
423 })
424 .add_renderer::<settings::SteppingGranularity>(|settings_field, file, _, window, cx| {
425 render_dropdown(*settings_field, file, window, cx)
426 })
427 .add_renderer::<settings::TerminalBlink>(|settings_field, file, _, window, cx| {
428 render_dropdown(*settings_field, file, window, cx)
429 })
430 .add_renderer::<settings::AlternateScroll>(|settings_field, file, _, window, cx| {
431 render_dropdown(*settings_field, file, window, cx)
432 })
433 .add_renderer::<settings::CursorShapeContent>(|settings_field, file, _, window, cx| {
434 render_dropdown(*settings_field, file, window, cx)
435 });
436
437 // todo(settings_ui): Figure out how we want to handle discriminant unions
438 // .add_renderer::<ThemeSelection>(|settings_field, file, _, window, cx| {
439 // render_dropdown(*settings_field, file, window, cx)
440 // });
441}
442
443pub fn open_settings_editor(cx: &mut App) -> anyhow::Result<WindowHandle<SettingsWindow>> {
444 let existing_window = cx
445 .windows()
446 .into_iter()
447 .find_map(|window| window.downcast::<SettingsWindow>());
448
449 if let Some(existing_window) = existing_window {
450 existing_window
451 .update(cx, |_, window, _| {
452 window.activate_window();
453 })
454 .ok();
455 return Ok(existing_window);
456 }
457
458 cx.open_window(
459 WindowOptions {
460 titlebar: Some(TitlebarOptions {
461 title: Some("Settings Window".into()),
462 appears_transparent: true,
463 traffic_light_position: Some(point(px(12.0), px(12.0))),
464 }),
465 focus: true,
466 show: true,
467 kind: gpui::WindowKind::Normal,
468 window_background: cx.theme().window_background_appearance(),
469 window_min_size: Some(size(px(800.), px(600.))), // 4:3 Aspect Ratio
470 ..Default::default()
471 },
472 |window, cx| cx.new(|cx| SettingsWindow::new(window, cx)),
473 )
474}
475
476/// The current sub page path that is selected.
477/// If this is empty the selected page is rendered,
478/// otherwise the last sub page gets rendered.
479///
480/// Global so that `pick` and `pick_mut` callbacks can access it
481/// and use it to dynamically render sub pages (e.g. for language settings)
482static SUB_PAGE_STACK: LazyLock<RwLock<Vec<SubPage>>> = LazyLock::new(|| RwLock::new(Vec::new()));
483
484fn sub_page_stack() -> std::sync::RwLockReadGuard<'static, Vec<SubPage>> {
485 SUB_PAGE_STACK
486 .read()
487 .expect("SUB_PAGE_STACK is never poisoned")
488}
489
490fn sub_page_stack_mut() -> std::sync::RwLockWriteGuard<'static, Vec<SubPage>> {
491 SUB_PAGE_STACK
492 .write()
493 .expect("SUB_PAGE_STACK is never poisoned")
494}
495
496pub struct SettingsWindow {
497 files: Vec<(SettingsUiFile, FocusHandle)>,
498 current_file: SettingsUiFile,
499 pages: Vec<SettingsPage>,
500 search_bar: Entity<Editor>,
501 search_task: Option<Task<()>>,
502 navbar_entry: usize, // Index into pages - should probably be (usize, Option<usize>) for section + page
503 navbar_entries: Vec<NavBarEntry>,
504 list_handle: UniformListScrollHandle,
505 search_matches: Vec<Vec<bool>>,
506 scroll_handle: ScrollHandle,
507 focus_handle: FocusHandle,
508 navbar_focus_handle: FocusHandle,
509 content_focus_handle: FocusHandle,
510 files_focus_handle: FocusHandle,
511}
512
513struct SubPage {
514 link: SubPageLink,
515 section_header: &'static str,
516}
517
518#[derive(PartialEq, Debug)]
519struct NavBarEntry {
520 title: &'static str,
521 is_root: bool,
522 expanded: bool,
523 page_index: usize,
524 item_index: Option<usize>,
525}
526
527struct SettingsPage {
528 title: &'static str,
529 items: Vec<SettingsPageItem>,
530}
531
532#[derive(PartialEq)]
533enum SettingsPageItem {
534 SectionHeader(&'static str),
535 SettingItem(SettingItem),
536 SubPageLink(SubPageLink),
537}
538
539impl std::fmt::Debug for SettingsPageItem {
540 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
541 match self {
542 SettingsPageItem::SectionHeader(header) => write!(f, "SectionHeader({})", header),
543 SettingsPageItem::SettingItem(setting_item) => {
544 write!(f, "SettingItem({})", setting_item.title)
545 }
546 SettingsPageItem::SubPageLink(sub_page_link) => {
547 write!(f, "SubPageLink({})", sub_page_link.title)
548 }
549 }
550 }
551}
552
553impl SettingsPageItem {
554 fn render(
555 &self,
556 file: SettingsUiFile,
557 section_header: &'static str,
558 is_last: bool,
559 window: &mut Window,
560 cx: &mut Context<SettingsWindow>,
561 ) -> AnyElement {
562 match self {
563 SettingsPageItem::SectionHeader(header) => v_flex()
564 .w_full()
565 .gap_1()
566 .child(
567 Label::new(SharedString::new_static(header))
568 .size(LabelSize::XSmall)
569 .color(Color::Muted)
570 .buffer_font(cx),
571 )
572 .child(Divider::horizontal().color(ui::DividerColor::BorderVariant))
573 .into_any_element(),
574 SettingsPageItem::SettingItem(setting_item) => {
575 let renderer = cx.default_global::<SettingFieldRenderer>().clone();
576 let (found_in_file, found) = setting_item.field.file_set_in(file.clone(), cx);
577 let file_set_in = SettingsUiFile::from_settings(found_in_file);
578
579 h_flex()
580 .id(setting_item.title)
581 .w_full()
582 .gap_2()
583 .flex_wrap()
584 .justify_between()
585 .map(|this| {
586 if is_last {
587 this.pb_6()
588 } else {
589 this.pb_4()
590 .border_b_1()
591 .border_color(cx.theme().colors().border_variant)
592 }
593 })
594 .child(
595 v_flex()
596 .max_w_1_2()
597 .flex_shrink()
598 .child(
599 h_flex()
600 .w_full()
601 .gap_1()
602 .child(Label::new(SharedString::new_static(setting_item.title)))
603 .when_some(
604 file_set_in.filter(|file_set_in| file_set_in != &file),
605 |this, file_set_in| {
606 this.child(
607 Label::new(format!(
608 "— set in {}",
609 file_set_in.name()
610 ))
611 .color(Color::Muted)
612 .size(LabelSize::Small),
613 )
614 },
615 ),
616 )
617 .child(
618 Label::new(SharedString::new_static(setting_item.description))
619 .size(LabelSize::Small)
620 .color(Color::Muted),
621 ),
622 )
623 .child(if cfg!(debug_assertions) && !found {
624 Button::new("no-default-field", "NO DEFAULT")
625 .size(ButtonSize::Medium)
626 .icon(IconName::XCircle)
627 .icon_position(IconPosition::Start)
628 .icon_color(Color::Error)
629 .icon_size(IconSize::Small)
630 .style(ButtonStyle::Outlined)
631 .into_any_element()
632 } else {
633 renderer.render(
634 setting_item.field.as_ref(),
635 file,
636 setting_item.metadata.as_deref(),
637 window,
638 cx,
639 )
640 })
641 .into_any_element()
642 }
643 SettingsPageItem::SubPageLink(sub_page_link) => h_flex()
644 .id(sub_page_link.title)
645 .w_full()
646 .gap_2()
647 .flex_wrap()
648 .justify_between()
649 .when(!is_last, |this| {
650 this.pb_4()
651 .border_b_1()
652 .border_color(cx.theme().colors().border_variant)
653 })
654 .child(
655 v_flex()
656 .max_w_1_2()
657 .flex_shrink()
658 .child(Label::new(SharedString::new_static(sub_page_link.title))),
659 )
660 .child(
661 Button::new(("sub-page".into(), sub_page_link.title), "Configure")
662 .size(ButtonSize::Medium)
663 .icon(IconName::ChevronRight)
664 .icon_position(IconPosition::End)
665 .icon_color(Color::Muted)
666 .icon_size(IconSize::Small)
667 .style(ButtonStyle::Outlined),
668 )
669 .on_click({
670 let sub_page_link = sub_page_link.clone();
671 cx.listener(move |this, _, _, cx| {
672 this.push_sub_page(sub_page_link.clone(), section_header, cx)
673 })
674 })
675 .into_any_element(),
676 }
677 }
678}
679
680struct SettingItem {
681 title: &'static str,
682 description: &'static str,
683 field: Box<dyn AnySettingField>,
684 metadata: Option<Box<SettingsFieldMetadata>>,
685 files: FileMask,
686}
687
688#[derive(PartialEq, Eq, Clone, Copy)]
689struct FileMask(u8);
690
691impl std::fmt::Debug for FileMask {
692 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
693 write!(f, "FileMask(")?;
694 let mut items = vec![];
695
696 if self.contains(USER) {
697 items.push("USER");
698 }
699 if self.contains(LOCAL) {
700 items.push("LOCAL");
701 }
702 if self.contains(SERVER) {
703 items.push("SERVER");
704 }
705
706 write!(f, "{})", items.join(" | "))
707 }
708}
709
710const USER: FileMask = FileMask(1 << 0);
711const LOCAL: FileMask = FileMask(1 << 2);
712const SERVER: FileMask = FileMask(1 << 3);
713
714impl std::ops::BitAnd for FileMask {
715 type Output = Self;
716
717 fn bitand(self, other: Self) -> Self {
718 Self(self.0 & other.0)
719 }
720}
721
722impl std::ops::BitOr for FileMask {
723 type Output = Self;
724
725 fn bitor(self, other: Self) -> Self {
726 Self(self.0 | other.0)
727 }
728}
729
730impl FileMask {
731 fn contains(&self, other: FileMask) -> bool {
732 self.0 & other.0 != 0
733 }
734}
735
736impl PartialEq for SettingItem {
737 fn eq(&self, other: &Self) -> bool {
738 self.title == other.title
739 && self.description == other.description
740 && (match (&self.metadata, &other.metadata) {
741 (None, None) => true,
742 (Some(m1), Some(m2)) => m1.placeholder == m2.placeholder,
743 _ => false,
744 })
745 }
746}
747
748#[derive(Clone)]
749struct SubPageLink {
750 title: &'static str,
751 files: FileMask,
752 render: Arc<
753 dyn Fn(&mut SettingsWindow, &mut Window, &mut Context<SettingsWindow>) -> AnyElement
754 + 'static
755 + Send
756 + Sync,
757 >,
758}
759
760impl PartialEq for SubPageLink {
761 fn eq(&self, other: &Self) -> bool {
762 self.title == other.title
763 }
764}
765
766#[allow(unused)]
767#[derive(Clone, PartialEq)]
768enum SettingsUiFile {
769 User, // Uses all settings.
770 Local((WorktreeId, Arc<RelPath>)), // Has a special name, and special set of settings
771 Server(&'static str), // Uses a special name, and the user settings
772}
773
774impl SettingsUiFile {
775 fn name(&self) -> SharedString {
776 match self {
777 SettingsUiFile::User => SharedString::new_static("User"),
778 // TODO is PathStyle::local() ever not appropriate?
779 SettingsUiFile::Local((_, path)) => {
780 format!("Local ({})", path.display(PathStyle::local())).into()
781 }
782 SettingsUiFile::Server(file) => format!("Server ({})", file).into(),
783 }
784 }
785
786 fn from_settings(file: settings::SettingsFile) -> Option<Self> {
787 Some(match file {
788 settings::SettingsFile::User => SettingsUiFile::User,
789 settings::SettingsFile::Local(location) => SettingsUiFile::Local(location),
790 settings::SettingsFile::Server => SettingsUiFile::Server("todo: server name"),
791 settings::SettingsFile::Default => return None,
792 })
793 }
794
795 fn to_settings(&self) -> settings::SettingsFile {
796 match self {
797 SettingsUiFile::User => settings::SettingsFile::User,
798 SettingsUiFile::Local(location) => settings::SettingsFile::Local(location.clone()),
799 SettingsUiFile::Server(_) => settings::SettingsFile::Server,
800 }
801 }
802
803 fn mask(&self) -> FileMask {
804 match self {
805 SettingsUiFile::User => USER,
806 SettingsUiFile::Local(_) => LOCAL,
807 SettingsUiFile::Server(_) => SERVER,
808 }
809 }
810}
811
812impl SettingsWindow {
813 pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
814 let font_family_cache = theme::FontFamilyCache::global(cx);
815
816 cx.spawn(async move |this, cx| {
817 font_family_cache.prefetch(cx).await;
818 this.update(cx, |_, cx| {
819 cx.notify();
820 })
821 })
822 .detach();
823
824 let current_file = SettingsUiFile::User;
825 let search_bar = cx.new(|cx| {
826 let mut editor = Editor::single_line(window, cx);
827 editor.set_placeholder_text("Search settings…", window, cx);
828 editor
829 });
830
831 cx.subscribe(&search_bar, |this, _, event: &EditorEvent, cx| {
832 let EditorEvent::Edited { transaction_id: _ } = event else {
833 return;
834 };
835
836 this.update_matches(cx);
837 })
838 .detach();
839
840 cx.observe_global_in::<SettingsStore>(window, move |this, _, cx| {
841 this.fetch_files(cx);
842 cx.notify();
843 })
844 .detach();
845
846 let mut this = Self {
847 files: vec![],
848 current_file: current_file,
849 pages: vec![],
850 navbar_entries: vec![],
851 navbar_entry: 0,
852 list_handle: UniformListScrollHandle::default(),
853 search_bar,
854 search_task: None,
855 search_matches: vec![],
856 scroll_handle: ScrollHandle::new(),
857 focus_handle: cx.focus_handle(),
858 navbar_focus_handle: cx
859 .focus_handle()
860 .tab_index(NAVBAR_CONTAINER_TAB_INDEX)
861 .tab_stop(false),
862 content_focus_handle: cx
863 .focus_handle()
864 .tab_index(CONTENT_CONTAINER_TAB_INDEX)
865 .tab_stop(false),
866 files_focus_handle: cx.focus_handle().tab_stop(false),
867 };
868
869 this.fetch_files(cx);
870 this.build_ui(cx);
871
872 this.search_bar.update(cx, |editor, cx| {
873 editor.focus_handle(cx).focus(window);
874 });
875
876 this
877 }
878
879 fn toggle_navbar_entry(&mut self, ix: usize) {
880 // We can only toggle root entries
881 if !self.navbar_entries[ix].is_root {
882 return;
883 }
884
885 let toggle_page_index = self.page_index_from_navbar_index(ix);
886 let selected_page_index = self.page_index_from_navbar_index(self.navbar_entry);
887
888 let expanded = &mut self.navbar_entries[ix].expanded;
889 *expanded = !*expanded;
890 // if currently selected page is a child of the parent page we are folding,
891 // set the current page to the parent page
892 if !*expanded && selected_page_index == toggle_page_index {
893 self.navbar_entry = ix;
894 }
895 }
896
897 fn build_navbar(&mut self) {
898 let mut prev_navbar_state = HashMap::new();
899 let mut root_entry = "";
900 let mut prev_selected_entry = None;
901 for (index, entry) in self.navbar_entries.iter().enumerate() {
902 let sub_entry_title;
903 if entry.is_root {
904 sub_entry_title = None;
905 root_entry = entry.title;
906 } else {
907 sub_entry_title = Some(entry.title);
908 }
909 let key = (root_entry, sub_entry_title);
910 if index == self.navbar_entry {
911 prev_selected_entry = Some(key);
912 }
913 prev_navbar_state.insert(key, entry.expanded);
914 }
915
916 let mut navbar_entries = Vec::with_capacity(self.navbar_entries.len());
917 for (page_index, page) in self.pages.iter().enumerate() {
918 navbar_entries.push(NavBarEntry {
919 title: page.title,
920 is_root: true,
921 expanded: false,
922 page_index,
923 item_index: None,
924 });
925
926 for (item_index, item) in page.items.iter().enumerate() {
927 let SettingsPageItem::SectionHeader(title) = item else {
928 continue;
929 };
930 navbar_entries.push(NavBarEntry {
931 title,
932 is_root: false,
933 expanded: false,
934 page_index,
935 item_index: Some(item_index),
936 });
937 }
938 }
939
940 let mut root_entry = "";
941 let mut found_nav_entry = false;
942 for (index, entry) in navbar_entries.iter_mut().enumerate() {
943 let sub_entry_title;
944 if entry.is_root {
945 root_entry = entry.title;
946 sub_entry_title = None;
947 } else {
948 sub_entry_title = Some(entry.title);
949 };
950 let key = (root_entry, sub_entry_title);
951 if Some(key) == prev_selected_entry {
952 self.navbar_entry = index;
953 found_nav_entry = true;
954 }
955 entry.expanded = *prev_navbar_state.get(&key).unwrap_or(&false);
956 }
957 if !found_nav_entry {
958 self.navbar_entry = 0;
959 }
960 self.navbar_entries = navbar_entries;
961 }
962
963 fn visible_navbar_entries(&self) -> impl Iterator<Item = (usize, &NavBarEntry)> {
964 let mut index = 0;
965 let entries = &self.navbar_entries;
966 let search_matches = &self.search_matches;
967 std::iter::from_fn(move || {
968 while index < entries.len() {
969 let entry = &entries[index];
970 let included_in_search = if let Some(item_index) = entry.item_index {
971 search_matches[entry.page_index][item_index]
972 } else {
973 search_matches[entry.page_index].iter().any(|b| *b)
974 || search_matches[entry.page_index].is_empty()
975 };
976 if included_in_search {
977 break;
978 }
979 index += 1;
980 }
981 if index >= self.navbar_entries.len() {
982 return None;
983 }
984 let entry = &entries[index];
985 let entry_index = index;
986
987 index += 1;
988 if entry.is_root && !entry.expanded {
989 while index < entries.len() {
990 if entries[index].is_root {
991 break;
992 }
993 index += 1;
994 }
995 }
996
997 return Some((entry_index, entry));
998 })
999 }
1000
1001 fn filter_matches_to_file(&mut self) {
1002 let current_file = self.current_file.mask();
1003 for (page, page_filter) in std::iter::zip(&self.pages, &mut self.search_matches) {
1004 let mut header_index = 0;
1005 let mut any_found_since_last_header = true;
1006
1007 for (index, item) in page.items.iter().enumerate() {
1008 match item {
1009 SettingsPageItem::SectionHeader(_) => {
1010 if !any_found_since_last_header {
1011 page_filter[header_index] = false;
1012 }
1013 header_index = index;
1014 any_found_since_last_header = false;
1015 }
1016 SettingsPageItem::SettingItem(setting_item) => {
1017 if !setting_item.files.contains(current_file) {
1018 page_filter[index] = false;
1019 } else {
1020 any_found_since_last_header = true;
1021 }
1022 }
1023 SettingsPageItem::SubPageLink(sub_page_link) => {
1024 if !sub_page_link.files.contains(current_file) {
1025 page_filter[index] = false;
1026 } else {
1027 any_found_since_last_header = true;
1028 }
1029 }
1030 }
1031 }
1032 if let Some(last_header) = page_filter.get_mut(header_index)
1033 && !any_found_since_last_header
1034 {
1035 *last_header = false;
1036 }
1037 }
1038 }
1039
1040 fn update_matches(&mut self, cx: &mut Context<SettingsWindow>) {
1041 self.search_task.take();
1042 let query = self.search_bar.read(cx).text(cx);
1043 if query.is_empty() {
1044 for page in &mut self.search_matches {
1045 page.fill(true);
1046 }
1047 self.filter_matches_to_file();
1048 cx.notify();
1049 return;
1050 }
1051
1052 struct ItemKey {
1053 page_index: usize,
1054 header_index: usize,
1055 item_index: usize,
1056 }
1057 let mut key_lut: Vec<ItemKey> = vec![];
1058 let mut candidates = Vec::default();
1059
1060 for (page_index, page) in self.pages.iter().enumerate() {
1061 let mut header_index = 0;
1062 for (item_index, item) in page.items.iter().enumerate() {
1063 let key_index = key_lut.len();
1064 match item {
1065 SettingsPageItem::SettingItem(item) => {
1066 candidates.push(StringMatchCandidate::new(key_index, item.title));
1067 candidates.push(StringMatchCandidate::new(key_index, item.description));
1068 }
1069 SettingsPageItem::SectionHeader(header) => {
1070 candidates.push(StringMatchCandidate::new(key_index, header));
1071 header_index = item_index;
1072 }
1073 SettingsPageItem::SubPageLink(sub_page_link) => {
1074 candidates.push(StringMatchCandidate::new(key_index, sub_page_link.title));
1075 }
1076 }
1077 key_lut.push(ItemKey {
1078 page_index,
1079 header_index,
1080 item_index,
1081 });
1082 }
1083 }
1084 let atomic_bool = AtomicBool::new(false);
1085
1086 self.search_task = Some(cx.spawn(async move |this, cx| {
1087 let string_matches = fuzzy::match_strings(
1088 candidates.as_slice(),
1089 &query,
1090 false,
1091 true,
1092 candidates.len(),
1093 &atomic_bool,
1094 cx.background_executor().clone(),
1095 );
1096 let string_matches = string_matches.await;
1097
1098 this.update(cx, |this, cx| {
1099 for page in &mut this.search_matches {
1100 page.fill(false);
1101 }
1102
1103 for string_match in string_matches {
1104 let ItemKey {
1105 page_index,
1106 header_index,
1107 item_index,
1108 } = key_lut[string_match.candidate_id];
1109 let page = &mut this.search_matches[page_index];
1110 page[header_index] = true;
1111 page[item_index] = true;
1112 }
1113 this.filter_matches_to_file();
1114 let first_navbar_entry_index = this
1115 .visible_navbar_entries()
1116 .next()
1117 .map(|e| e.0)
1118 .unwrap_or(0);
1119 this.navbar_entry = first_navbar_entry_index;
1120 cx.notify();
1121 })
1122 .ok();
1123 }));
1124 }
1125
1126 fn build_search_matches(&mut self) {
1127 self.search_matches = self
1128 .pages
1129 .iter()
1130 .map(|page| vec![true; page.items.len()])
1131 .collect::<Vec<_>>();
1132 }
1133
1134 fn build_ui(&mut self, cx: &mut Context<SettingsWindow>) {
1135 if self.pages.is_empty() {
1136 self.pages = page_data::settings_data();
1137 }
1138 self.build_search_matches();
1139 self.build_navbar();
1140
1141 self.update_matches(cx);
1142
1143 cx.notify();
1144 }
1145
1146 fn calculate_navbar_entry_from_scroll_position(&mut self) {
1147 let top = self.scroll_handle.top_item();
1148 let bottom = self.scroll_handle.bottom_item();
1149
1150 let scroll_index = (top + bottom) / 2;
1151 let scroll_index = scroll_index.clamp(top, bottom);
1152 let mut page_index = self.navbar_entry;
1153
1154 while !self.navbar_entries[page_index].is_root {
1155 page_index -= 1;
1156 }
1157
1158 if self.navbar_entries[page_index].expanded {
1159 let section_index = self
1160 .page_items()
1161 .take(scroll_index + 1)
1162 .filter(|item| matches!(item, SettingsPageItem::SectionHeader(_)))
1163 .count();
1164
1165 self.navbar_entry = section_index + page_index;
1166 }
1167 }
1168
1169 fn fetch_files(&mut self, cx: &mut Context<SettingsWindow>) {
1170 let prev_files = self.files.clone();
1171 let settings_store = cx.global::<SettingsStore>();
1172 let mut ui_files = vec![];
1173 let all_files = settings_store.get_all_files();
1174 for file in all_files {
1175 let Some(settings_ui_file) = SettingsUiFile::from_settings(file) else {
1176 continue;
1177 };
1178 let focus_handle = prev_files
1179 .iter()
1180 .find_map(|(prev_file, handle)| {
1181 (prev_file == &settings_ui_file).then(|| handle.clone())
1182 })
1183 .unwrap_or_else(|| cx.focus_handle());
1184 ui_files.push((settings_ui_file, focus_handle));
1185 }
1186 ui_files.reverse();
1187 self.files = ui_files;
1188 let current_file_still_exists = self
1189 .files
1190 .iter()
1191 .any(|(file, _)| file == &self.current_file);
1192 if !current_file_still_exists {
1193 self.change_file(0, cx);
1194 }
1195 }
1196
1197 fn change_file(&mut self, ix: usize, cx: &mut Context<SettingsWindow>) {
1198 if ix >= self.files.len() {
1199 self.current_file = SettingsUiFile::User;
1200 return;
1201 }
1202 if self.files[ix].0 == self.current_file {
1203 return;
1204 }
1205 self.current_file = self.files[ix].0.clone();
1206 // self.navbar_entry = 0;
1207 self.build_ui(cx);
1208 }
1209
1210 fn render_files(
1211 &self,
1212 _window: &mut Window,
1213 cx: &mut Context<SettingsWindow>,
1214 ) -> impl IntoElement {
1215 h_flex()
1216 .w_full()
1217 .gap_1()
1218 .justify_between()
1219 .child(
1220 h_flex()
1221 .id("file_buttons_container")
1222 .w_64() // Temporary fix until long-term solution is a fixed set of buttons representing a file location (User, Project, and Remote)
1223 .gap_1()
1224 .overflow_x_scroll()
1225 .children(
1226 self.files
1227 .iter()
1228 .enumerate()
1229 .map(|(ix, (file, focus_handle))| {
1230 Button::new(ix, file.name())
1231 .toggle_state(file == &self.current_file)
1232 .selected_style(ButtonStyle::Tinted(ui::TintColor::Accent))
1233 .track_focus(focus_handle)
1234 .on_click(cx.listener(
1235 move |this, evt: &gpui::ClickEvent, window, cx| {
1236 this.change_file(ix, cx);
1237 if evt.is_keyboard() {
1238 this.focus_first_nav_item(window, cx);
1239 }
1240 },
1241 ))
1242 }),
1243 ),
1244 )
1245 .child(Button::new("temp", "Edit in settings.json").style(ButtonStyle::Outlined)) // This should be replaced by the actual, functioning button
1246 }
1247
1248 fn render_search(&self, _window: &mut Window, cx: &mut App) -> Div {
1249 h_flex()
1250 .py_1()
1251 .px_1p5()
1252 .gap_1p5()
1253 .rounded_sm()
1254 .bg(cx.theme().colors().editor_background)
1255 .border_1()
1256 .border_color(cx.theme().colors().border)
1257 .child(Icon::new(IconName::MagnifyingGlass).color(Color::Muted))
1258 .child(self.search_bar.clone())
1259 }
1260
1261 fn render_nav(
1262 &self,
1263 window: &mut Window,
1264 cx: &mut Context<SettingsWindow>,
1265 ) -> impl IntoElement {
1266 let visible_count = self.visible_navbar_entries().count();
1267 let nav_background = cx.theme().colors().panel_background;
1268 let focus_keybind_label = if self.navbar_focus_handle.contains_focused(window, cx) {
1269 "Focus Content"
1270 } else {
1271 "Focus Navbar"
1272 };
1273
1274 v_flex()
1275 .w_64()
1276 .p_2p5()
1277 .pt_10()
1278 .gap_3()
1279 .flex_none()
1280 .border_r_1()
1281 .border_color(cx.theme().colors().border)
1282 .bg(nav_background)
1283 .child(self.render_search(window, cx))
1284 .child(
1285 v_flex()
1286 .flex_grow()
1287 .track_focus(&self.navbar_focus_handle)
1288 .tab_group()
1289 .tab_index(NAVBAR_GROUP_TAB_INDEX)
1290 .child(
1291 uniform_list(
1292 "settings-ui-nav-bar",
1293 visible_count,
1294 cx.processor(move |this, range: Range<usize>, _, cx| {
1295 let entries: Vec<_> = this.visible_navbar_entries().collect();
1296 range
1297 .filter_map(|ix| entries.get(ix).copied())
1298 .map(|(ix, entry)| {
1299 TreeViewItem::new(
1300 ("settings-ui-navbar-entry", ix),
1301 entry.title,
1302 )
1303 .tab_index(0)
1304 .root_item(entry.is_root)
1305 .toggle_state(this.is_navbar_entry_selected(ix))
1306 .when(entry.is_root, |item| {
1307 item.expanded(entry.expanded).on_toggle(cx.listener(
1308 move |this, _, _, cx| {
1309 this.toggle_navbar_entry(ix);
1310 cx.notify();
1311 },
1312 ))
1313 })
1314 .on_click(cx.listener(
1315 move |this, evt: &gpui::ClickEvent, window, cx| {
1316 this.navbar_entry = ix;
1317
1318 if !this.navbar_entries[ix].is_root {
1319 let mut selected_page_ix = ix;
1320
1321 while !this.navbar_entries[selected_page_ix]
1322 .is_root
1323 {
1324 selected_page_ix -= 1;
1325 }
1326
1327 let section_header = ix - selected_page_ix;
1328
1329 if let Some(section_index) = this
1330 .page_items()
1331 .enumerate()
1332 .filter(|item| {
1333 matches!(
1334 item.1,
1335 SettingsPageItem::SectionHeader(_)
1336 )
1337 })
1338 .take(section_header)
1339 .last()
1340 .map(|pair| pair.0)
1341 {
1342 this.scroll_handle
1343 .scroll_to_top_of_item(section_index);
1344 }
1345 }
1346
1347 if evt.is_keyboard() {
1348 // todo(settings_ui): Focus the actual item and scroll to it
1349 this.focus_first_content_item(window, cx);
1350 }
1351 cx.notify();
1352 },
1353 ))
1354 .into_any_element()
1355 })
1356 .collect()
1357 }),
1358 )
1359 .track_scroll(self.list_handle.clone())
1360 .flex_grow(),
1361 )
1362 .vertical_scrollbar_for(self.list_handle.clone(), window, cx),
1363 )
1364 .child(
1365 h_flex()
1366 .w_full()
1367 .p_2()
1368 .pb_0p5()
1369 .border_t_1()
1370 .border_color(cx.theme().colors().border_variant)
1371 .children(
1372 KeyBinding::for_action(&ToggleFocusNav, window, cx).map(|this| {
1373 KeybindingHint::new(
1374 this,
1375 cx.theme().colors().surface_background.opacity(0.5),
1376 )
1377 .suffix(focus_keybind_label)
1378 }),
1379 ),
1380 )
1381 }
1382
1383 fn focus_first_nav_item(&self, window: &mut Window, cx: &mut Context<Self>) {
1384 self.navbar_focus_handle.focus(window);
1385 window.focus_next();
1386 cx.notify();
1387 }
1388
1389 fn focus_first_content_item(&self, window: &mut Window, cx: &mut Context<Self>) {
1390 self.content_focus_handle.focus(window);
1391 window.focus_next();
1392 cx.notify();
1393 }
1394
1395 fn page_items(&self) -> impl Iterator<Item = &SettingsPageItem> {
1396 let page_idx = self.current_page_index();
1397
1398 self.current_page()
1399 .items
1400 .iter()
1401 .enumerate()
1402 .filter_map(move |(item_index, item)| {
1403 self.search_matches[page_idx][item_index].then_some(item)
1404 })
1405 }
1406
1407 fn render_sub_page_breadcrumbs(&self) -> impl IntoElement {
1408 let mut items = vec![];
1409 items.push(self.current_page().title);
1410 items.extend(
1411 sub_page_stack()
1412 .iter()
1413 .flat_map(|page| [page.section_header, page.link.title]),
1414 );
1415
1416 let last = items.pop().unwrap();
1417 h_flex()
1418 .gap_1()
1419 .children(
1420 items
1421 .into_iter()
1422 .flat_map(|item| [item, "/"])
1423 .map(|item| Label::new(item).color(Color::Muted)),
1424 )
1425 .child(Label::new(last))
1426 }
1427
1428 fn render_page_items<'a, Items: Iterator<Item = &'a SettingsPageItem>>(
1429 &self,
1430 items: Items,
1431 window: &mut Window,
1432 cx: &mut Context<SettingsWindow>,
1433 ) -> impl IntoElement {
1434 let mut page_content = v_flex()
1435 .id("settings-ui-page")
1436 .size_full()
1437 .gap_4()
1438 .overflow_y_scroll()
1439 .track_scroll(&self.scroll_handle);
1440
1441 let items: Vec<_> = items.collect();
1442 let items_len = items.len();
1443 let mut section_header = None;
1444
1445 let has_active_search = !self.search_bar.read(cx).is_empty(cx);
1446 let has_no_results = items_len == 0 && has_active_search;
1447
1448 if has_no_results {
1449 let search_query = self.search_bar.read(cx).text(cx);
1450 page_content = page_content.child(
1451 v_flex()
1452 .size_full()
1453 .items_center()
1454 .justify_center()
1455 .gap_1()
1456 .child(div().child("No Results"))
1457 .child(
1458 div()
1459 .text_sm()
1460 .text_color(cx.theme().colors().text_muted)
1461 .child(format!("No settings match \"{}\"", search_query)),
1462 ),
1463 )
1464 } else {
1465 let last_non_header_index = items
1466 .iter()
1467 .enumerate()
1468 .rev()
1469 .find(|(_, item)| !matches!(item, SettingsPageItem::SectionHeader(_)))
1470 .map(|(index, _)| index);
1471
1472 page_content =
1473 page_content.children(items.clone().into_iter().enumerate().map(|(index, item)| {
1474 let no_bottom_border = items
1475 .get(index + 1)
1476 .map(|next_item| matches!(next_item, SettingsPageItem::SectionHeader(_)))
1477 .unwrap_or(false);
1478 let is_last = Some(index) == last_non_header_index;
1479
1480 if let SettingsPageItem::SectionHeader(header) = item {
1481 section_header = Some(*header);
1482 }
1483 item.render(
1484 self.current_file.clone(),
1485 section_header.expect("All items rendered after a section header"),
1486 no_bottom_border || is_last,
1487 window,
1488 cx,
1489 )
1490 }))
1491 }
1492 page_content
1493 }
1494
1495 fn render_page(
1496 &mut self,
1497 window: &mut Window,
1498 cx: &mut Context<SettingsWindow>,
1499 ) -> impl IntoElement {
1500 let page_header;
1501 let page_content;
1502
1503 if sub_page_stack().len() == 0 {
1504 page_header = self.render_files(window, cx).into_any_element();
1505 page_content = self
1506 .render_page_items(self.page_items(), window, cx)
1507 .into_any_element();
1508 } else {
1509 page_header = h_flex()
1510 .ml_neg_1p5()
1511 .gap_1()
1512 .child(
1513 IconButton::new("back-btn", IconName::ArrowLeft)
1514 .icon_size(IconSize::Small)
1515 .shape(IconButtonShape::Square)
1516 .on_click(cx.listener(|this, _, _, cx| {
1517 this.pop_sub_page(cx);
1518 })),
1519 )
1520 .child(self.render_sub_page_breadcrumbs())
1521 .into_any_element();
1522
1523 let active_page_render_fn = sub_page_stack().last().unwrap().link.render.clone();
1524 page_content = (active_page_render_fn)(self, window, cx);
1525 }
1526
1527 return v_flex()
1528 .w_full()
1529 .pt_4()
1530 .pb_6()
1531 .px_6()
1532 .gap_4()
1533 .track_focus(&self.content_focus_handle)
1534 .bg(cx.theme().colors().editor_background)
1535 .vertical_scrollbar_for(self.scroll_handle.clone(), window, cx)
1536 .child(page_header)
1537 .child(
1538 div()
1539 .size_full()
1540 .track_focus(&self.content_focus_handle)
1541 .tab_group()
1542 .tab_index(CONTENT_GROUP_TAB_INDEX)
1543 .child(page_content),
1544 );
1545 }
1546
1547 fn current_page_index(&self) -> usize {
1548 self.page_index_from_navbar_index(self.navbar_entry)
1549 }
1550
1551 fn current_page(&self) -> &SettingsPage {
1552 &self.pages[self.current_page_index()]
1553 }
1554
1555 fn page_index_from_navbar_index(&self, index: usize) -> usize {
1556 if self.navbar_entries.is_empty() {
1557 return 0;
1558 }
1559
1560 self.navbar_entries[index].page_index
1561 }
1562
1563 fn is_navbar_entry_selected(&self, ix: usize) -> bool {
1564 ix == self.navbar_entry
1565 }
1566
1567 fn push_sub_page(
1568 &mut self,
1569 sub_page_link: SubPageLink,
1570 section_header: &'static str,
1571 cx: &mut Context<SettingsWindow>,
1572 ) {
1573 sub_page_stack_mut().push(SubPage {
1574 link: sub_page_link,
1575 section_header,
1576 });
1577 cx.notify();
1578 }
1579
1580 fn pop_sub_page(&mut self, cx: &mut Context<SettingsWindow>) {
1581 sub_page_stack_mut().pop();
1582 cx.notify();
1583 }
1584
1585 fn focus_file_at_index(&mut self, index: usize, window: &mut Window) {
1586 if let Some((_, handle)) = self.files.get(index) {
1587 handle.focus(window);
1588 }
1589 }
1590
1591 fn focused_file_index(&self, window: &Window, cx: &Context<Self>) -> usize {
1592 if self.files_focus_handle.contains_focused(window, cx)
1593 && let Some(index) = self
1594 .files
1595 .iter()
1596 .position(|(_, handle)| handle.is_focused(window))
1597 {
1598 return index;
1599 }
1600 if let Some(current_file_index) = self
1601 .files
1602 .iter()
1603 .position(|(file, _)| file == &self.current_file)
1604 {
1605 return current_file_index;
1606 }
1607 0
1608 }
1609}
1610
1611impl Render for SettingsWindow {
1612 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1613 let ui_font = theme::setup_ui_font(window, cx);
1614 self.calculate_navbar_entry_from_scroll_position();
1615
1616 div()
1617 .id("settings-window")
1618 .key_context("SettingsWindow")
1619 .track_focus(&self.focus_handle)
1620 .on_action(|_: &Minimize, window, _cx| {
1621 window.minimize_window();
1622 })
1623 .on_action(cx.listener(|this, _: &search::FocusSearch, window, cx| {
1624 this.search_bar.focus_handle(cx).focus(window);
1625 }))
1626 .on_action(cx.listener(|this, _: &ToggleFocusNav, window, cx| {
1627 if this.navbar_focus_handle.contains_focused(window, cx) {
1628 this.focus_first_content_item(window, cx);
1629 } else {
1630 this.focus_first_nav_item(window, cx);
1631 }
1632 }))
1633 .on_action(
1634 cx.listener(|this, FocusFile(file_index): &FocusFile, window, _| {
1635 this.focus_file_at_index(*file_index as usize, window);
1636 }),
1637 )
1638 .on_action(cx.listener(|this, _: &FocusNextFile, window, cx| {
1639 let next_index = usize::min(
1640 this.focused_file_index(window, cx) + 1,
1641 this.files.len().saturating_sub(1),
1642 );
1643 this.focus_file_at_index(next_index, window);
1644 }))
1645 .on_action(cx.listener(|this, _: &FocusPreviousFile, window, cx| {
1646 let prev_index = this.focused_file_index(window, cx).saturating_sub(1);
1647 this.focus_file_at_index(prev_index, window);
1648 }))
1649 .on_action(|_: &menu::SelectNext, window, _| {
1650 window.focus_next();
1651 })
1652 .on_action(|_: &menu::SelectPrevious, window, _| {
1653 window.focus_prev();
1654 })
1655 .flex()
1656 .flex_row()
1657 .size_full()
1658 .font(ui_font)
1659 .bg(cx.theme().colors().background)
1660 .text_color(cx.theme().colors().text)
1661 .child(self.render_nav(window, cx))
1662 .child(self.render_page(window, cx))
1663 }
1664}
1665
1666fn update_settings_file(
1667 file: SettingsUiFile,
1668 cx: &mut App,
1669 update: impl 'static + Send + FnOnce(&mut SettingsContent, &App),
1670) -> Result<()> {
1671 match file {
1672 SettingsUiFile::Local((worktree_id, rel_path)) => {
1673 fn all_projects(cx: &App) -> impl Iterator<Item = Entity<project::Project>> {
1674 workspace::AppState::global(cx)
1675 .upgrade()
1676 .map(|app_state| {
1677 app_state
1678 .workspace_store
1679 .read(cx)
1680 .workspaces()
1681 .iter()
1682 .filter_map(|workspace| {
1683 Some(workspace.read(cx).ok()?.project().clone())
1684 })
1685 })
1686 .into_iter()
1687 .flatten()
1688 }
1689 let rel_path = rel_path.join(paths::local_settings_file_relative_path());
1690 let project = all_projects(cx).find(|project| {
1691 project.read_with(cx, |project, cx| {
1692 project.contains_local_settings_file(worktree_id, &rel_path, cx)
1693 })
1694 });
1695 let Some(project) = project else {
1696 anyhow::bail!(
1697 "Could not find worktree containing settings file: {}",
1698 &rel_path.display(PathStyle::local())
1699 );
1700 };
1701 project.update(cx, |project, cx| {
1702 project.update_local_settings_file(worktree_id, rel_path, cx, update);
1703 });
1704 return Ok(());
1705 }
1706 SettingsUiFile::User => {
1707 // todo(settings_ui) error?
1708 SettingsStore::global(cx).update_settings_file(<dyn fs::Fs>::global(cx), update);
1709 Ok(())
1710 }
1711 SettingsUiFile::Server(_) => unimplemented!(),
1712 }
1713}
1714
1715fn render_text_field<T: From<String> + Into<String> + AsRef<str> + Clone>(
1716 field: SettingField<T>,
1717 file: SettingsUiFile,
1718 metadata: Option<&SettingsFieldMetadata>,
1719 cx: &mut App,
1720) -> AnyElement {
1721 let (_, initial_text) =
1722 SettingsStore::global(cx).get_value_from_file(file.to_settings(), field.pick);
1723 let initial_text = initial_text.filter(|s| !s.as_ref().is_empty());
1724
1725 SettingsEditor::new()
1726 .tab_index(0)
1727 .when_some(initial_text, |editor, text| {
1728 editor.with_initial_text(text.as_ref().to_string())
1729 })
1730 .when_some(
1731 metadata.and_then(|metadata| metadata.placeholder),
1732 |editor, placeholder| editor.with_placeholder(placeholder),
1733 )
1734 .on_confirm({
1735 move |new_text, cx| {
1736 update_settings_file(file.clone(), cx, move |settings, _cx| {
1737 *(field.pick_mut)(settings) = new_text.map(Into::into);
1738 })
1739 .log_err(); // todo(settings_ui) don't log err
1740 }
1741 })
1742 .into_any_element()
1743}
1744
1745fn render_toggle_button<B: Into<bool> + From<bool> + Copy>(
1746 field: SettingField<B>,
1747 file: SettingsUiFile,
1748 cx: &mut App,
1749) -> AnyElement {
1750 let (_, value) = SettingsStore::global(cx).get_value_from_file(file.to_settings(), field.pick);
1751
1752 let toggle_state = if value.copied().map_or(false, Into::into) {
1753 ToggleState::Selected
1754 } else {
1755 ToggleState::Unselected
1756 };
1757
1758 Switch::new("toggle_button", toggle_state)
1759 .color(ui::SwitchColor::Accent)
1760 .on_click({
1761 move |state, _window, cx| {
1762 let state = *state == ui::ToggleState::Selected;
1763 update_settings_file(file.clone(), cx, move |settings, _cx| {
1764 *(field.pick_mut)(settings) = Some(state.into());
1765 })
1766 .log_err(); // todo(settings_ui) don't log err
1767 }
1768 })
1769 .tab_index(0_isize)
1770 .color(SwitchColor::Accent)
1771 .into_any_element()
1772}
1773
1774fn render_font_picker(
1775 field: SettingField<settings::FontFamilyName>,
1776 file: SettingsUiFile,
1777 window: &mut Window,
1778 cx: &mut App,
1779) -> AnyElement {
1780 let current_value = SettingsStore::global(cx)
1781 .get_value_from_file(file.to_settings(), field.pick)
1782 .1
1783 .cloned()
1784 .unwrap_or_else(|| SharedString::default().into());
1785
1786 let font_picker = cx.new(|cx| {
1787 ui_input::font_picker(
1788 current_value.clone().into(),
1789 move |font_name, cx| {
1790 update_settings_file(file.clone(), cx, move |settings, _cx| {
1791 *(field.pick_mut)(settings) = Some(font_name.into());
1792 })
1793 .log_err(); // todo(settings_ui) don't log err
1794 },
1795 window,
1796 cx,
1797 )
1798 });
1799
1800 PopoverMenu::new("font-picker")
1801 .menu(move |_window, _cx| Some(font_picker.clone()))
1802 .trigger(
1803 Button::new("font-family-button", current_value)
1804 .tab_index(0_isize)
1805 .style(ButtonStyle::Outlined)
1806 .size(ButtonSize::Medium)
1807 .icon(IconName::ChevronUpDown)
1808 .icon_color(Color::Muted)
1809 .icon_size(IconSize::Small)
1810 .icon_position(IconPosition::End),
1811 )
1812 .anchor(gpui::Corner::TopLeft)
1813 .offset(gpui::Point {
1814 x: px(0.0),
1815 y: px(2.0),
1816 })
1817 .with_handle(ui::PopoverMenuHandle::default())
1818 .into_any_element()
1819}
1820
1821fn render_number_field<T: NumberFieldType + Send + Sync>(
1822 field: SettingField<T>,
1823 file: SettingsUiFile,
1824 window: &mut Window,
1825 cx: &mut App,
1826) -> AnyElement {
1827 let (_, value) = SettingsStore::global(cx).get_value_from_file(file.to_settings(), field.pick);
1828 let value = value.copied().unwrap_or_else(T::min_value);
1829 NumberField::new("numeric_stepper", value, window, cx)
1830 .on_change({
1831 move |value, _window, cx| {
1832 let value = *value;
1833 update_settings_file(file.clone(), cx, move |settings, _cx| {
1834 *(field.pick_mut)(settings) = Some(value);
1835 })
1836 .log_err(); // todo(settings_ui) don't log err
1837 }
1838 })
1839 .tab_index(0)
1840 .into_any_element()
1841}
1842
1843fn render_dropdown<T>(
1844 field: SettingField<T>,
1845 file: SettingsUiFile,
1846 window: &mut Window,
1847 cx: &mut App,
1848) -> AnyElement
1849where
1850 T: strum::VariantArray + strum::VariantNames + Copy + PartialEq + Send + Sync + 'static,
1851{
1852 let variants = || -> &'static [T] { <T as strum::VariantArray>::VARIANTS };
1853 let labels = || -> &'static [&'static str] { <T as strum::VariantNames>::VARIANTS };
1854
1855 let (_, current_value) =
1856 SettingsStore::global(cx).get_value_from_file(file.to_settings(), field.pick);
1857 let current_value = current_value.copied().unwrap_or(variants()[0]);
1858
1859 let current_value_label =
1860 labels()[variants().iter().position(|v| *v == current_value).unwrap()];
1861
1862 DropdownMenu::new(
1863 "dropdown",
1864 current_value_label.to_title_case(),
1865 ContextMenu::build(window, cx, move |mut menu, _, _| {
1866 for (&value, &label) in std::iter::zip(variants(), labels()) {
1867 let file = file.clone();
1868 menu = menu.toggleable_entry(
1869 label.to_title_case(),
1870 value == current_value,
1871 IconPosition::Start,
1872 None,
1873 move |_, cx| {
1874 if value == current_value {
1875 return;
1876 }
1877 update_settings_file(file.clone(), cx, move |settings, _cx| {
1878 *(field.pick_mut)(settings) = Some(value);
1879 })
1880 .log_err(); // todo(settings_ui) don't log err
1881 },
1882 );
1883 }
1884 menu
1885 }),
1886 )
1887 .trigger_size(ButtonSize::Medium)
1888 .style(DropdownStyle::Outlined)
1889 .offset(gpui::Point {
1890 x: px(0.0),
1891 y: px(2.0),
1892 })
1893 .tab_index(0)
1894 .into_any_element()
1895}
1896
1897#[cfg(test)]
1898mod test {
1899
1900 use super::*;
1901
1902 impl SettingsWindow {
1903 fn navbar_entry(&self) -> usize {
1904 self.navbar_entry
1905 }
1906
1907 fn new_builder(window: &mut Window, cx: &mut Context<Self>) -> Self {
1908 let mut this = Self::new(window, cx);
1909 this.navbar_entries.clear();
1910 this.pages.clear();
1911 this
1912 }
1913
1914 fn build(mut self) -> Self {
1915 self.build_search_matches();
1916 self.build_navbar();
1917 self
1918 }
1919
1920 fn add_page(
1921 mut self,
1922 title: &'static str,
1923 build_page: impl Fn(SettingsPage) -> SettingsPage,
1924 ) -> Self {
1925 let page = SettingsPage {
1926 title,
1927 items: Vec::default(),
1928 };
1929
1930 self.pages.push(build_page(page));
1931 self
1932 }
1933
1934 fn search(&mut self, search_query: &str, window: &mut Window, cx: &mut Context<Self>) {
1935 self.search_task.take();
1936 self.search_bar.update(cx, |editor, cx| {
1937 editor.set_text(search_query, window, cx);
1938 });
1939 self.update_matches(cx);
1940 }
1941
1942 fn assert_search_results(&self, other: &Self) {
1943 // page index could be different because of filtered out pages
1944 #[derive(Debug, PartialEq)]
1945 struct EntryMinimal {
1946 is_root: bool,
1947 title: &'static str,
1948 }
1949 pretty_assertions::assert_eq!(
1950 other
1951 .visible_navbar_entries()
1952 .map(|(_, entry)| EntryMinimal {
1953 is_root: entry.is_root,
1954 title: entry.title,
1955 })
1956 .collect::<Vec<_>>(),
1957 self.visible_navbar_entries()
1958 .map(|(_, entry)| EntryMinimal {
1959 is_root: entry.is_root,
1960 title: entry.title,
1961 })
1962 .collect::<Vec<_>>(),
1963 );
1964 assert_eq!(
1965 self.current_page().items.iter().collect::<Vec<_>>(),
1966 other.page_items().collect::<Vec<_>>()
1967 );
1968 }
1969 }
1970
1971 impl SettingsPage {
1972 fn item(mut self, item: SettingsPageItem) -> Self {
1973 self.items.push(item);
1974 self
1975 }
1976 }
1977
1978 impl SettingsPageItem {
1979 fn basic_item(title: &'static str, description: &'static str) -> Self {
1980 SettingsPageItem::SettingItem(SettingItem {
1981 files: USER,
1982 title,
1983 description,
1984 field: Box::new(SettingField {
1985 pick: |settings_content| &settings_content.auto_update,
1986 pick_mut: |settings_content| &mut settings_content.auto_update,
1987 }),
1988 metadata: None,
1989 })
1990 }
1991 }
1992
1993 fn register_settings(cx: &mut App) {
1994 settings::init(cx);
1995 theme::init(theme::LoadThemes::JustBase, cx);
1996 workspace::init_settings(cx);
1997 project::Project::init_settings(cx);
1998 language::init(cx);
1999 editor::init(cx);
2000 menu::init();
2001 }
2002
2003 fn parse(input: &'static str, window: &mut Window, cx: &mut App) -> SettingsWindow {
2004 let mut pages: Vec<SettingsPage> = Vec::new();
2005 let mut expanded_pages = Vec::new();
2006 let mut selected_idx = None;
2007 let mut index = 0;
2008 let mut in_expanded_section = false;
2009
2010 for mut line in input
2011 .lines()
2012 .map(|line| line.trim())
2013 .filter(|line| !line.is_empty())
2014 {
2015 if let Some(pre) = line.strip_suffix('*') {
2016 assert!(selected_idx.is_none(), "Only one selected entry allowed");
2017 selected_idx = Some(index);
2018 line = pre;
2019 }
2020 let (kind, title) = line.split_once(" ").unwrap();
2021 assert_eq!(kind.len(), 1);
2022 let kind = kind.chars().next().unwrap();
2023 if kind == 'v' {
2024 let page_idx = pages.len();
2025 expanded_pages.push(page_idx);
2026 pages.push(SettingsPage {
2027 title,
2028 items: vec![],
2029 });
2030 index += 1;
2031 in_expanded_section = true;
2032 } else if kind == '>' {
2033 pages.push(SettingsPage {
2034 title,
2035 items: vec![],
2036 });
2037 index += 1;
2038 in_expanded_section = false;
2039 } else if kind == '-' {
2040 pages
2041 .last_mut()
2042 .unwrap()
2043 .items
2044 .push(SettingsPageItem::SectionHeader(title));
2045 if selected_idx == Some(index) && !in_expanded_section {
2046 panic!("Items in unexpanded sections cannot be selected");
2047 }
2048 index += 1;
2049 } else {
2050 panic!(
2051 "Entries must start with one of 'v', '>', or '-'\n line: {}",
2052 line
2053 );
2054 }
2055 }
2056
2057 let mut settings_window = SettingsWindow {
2058 files: Vec::default(),
2059 current_file: crate::SettingsUiFile::User,
2060 pages,
2061 search_bar: cx.new(|cx| Editor::single_line(window, cx)),
2062 navbar_entry: selected_idx.expect("Must have a selected navbar entry"),
2063 navbar_entries: Vec::default(),
2064 list_handle: UniformListScrollHandle::default(),
2065 search_matches: vec![],
2066 search_task: None,
2067 scroll_handle: ScrollHandle::new(),
2068 focus_handle: cx.focus_handle(),
2069 navbar_focus_handle: cx.focus_handle(),
2070 content_focus_handle: cx.focus_handle(),
2071 files_focus_handle: cx.focus_handle(),
2072 };
2073
2074 settings_window.build_search_matches();
2075 settings_window.build_navbar();
2076 for expanded_page_index in expanded_pages {
2077 for entry in &mut settings_window.navbar_entries {
2078 if entry.page_index == expanded_page_index && entry.is_root {
2079 entry.expanded = true;
2080 }
2081 }
2082 }
2083 settings_window
2084 }
2085
2086 #[track_caller]
2087 fn check_navbar_toggle(
2088 before: &'static str,
2089 toggle_page: &'static str,
2090 after: &'static str,
2091 window: &mut Window,
2092 cx: &mut App,
2093 ) {
2094 let mut settings_window = parse(before, window, cx);
2095 let toggle_page_idx = settings_window
2096 .pages
2097 .iter()
2098 .position(|page| page.title == toggle_page)
2099 .expect("page not found");
2100 let toggle_idx = settings_window
2101 .navbar_entries
2102 .iter()
2103 .position(|entry| entry.page_index == toggle_page_idx)
2104 .expect("page not found");
2105 settings_window.toggle_navbar_entry(toggle_idx);
2106
2107 let expected_settings_window = parse(after, window, cx);
2108
2109 pretty_assertions::assert_eq!(
2110 settings_window
2111 .visible_navbar_entries()
2112 .map(|(_, entry)| entry)
2113 .collect::<Vec<_>>(),
2114 expected_settings_window
2115 .visible_navbar_entries()
2116 .map(|(_, entry)| entry)
2117 .collect::<Vec<_>>(),
2118 );
2119 pretty_assertions::assert_eq!(
2120 settings_window.navbar_entries[settings_window.navbar_entry()],
2121 expected_settings_window.navbar_entries[expected_settings_window.navbar_entry()],
2122 );
2123 }
2124
2125 macro_rules! check_navbar_toggle {
2126 ($name:ident, before: $before:expr, toggle_page: $toggle_page:expr, after: $after:expr) => {
2127 #[gpui::test]
2128 fn $name(cx: &mut gpui::TestAppContext) {
2129 let window = cx.add_empty_window();
2130 window.update(|window, cx| {
2131 register_settings(cx);
2132 check_navbar_toggle($before, $toggle_page, $after, window, cx);
2133 });
2134 }
2135 };
2136 }
2137
2138 check_navbar_toggle!(
2139 navbar_basic_open,
2140 before: r"
2141 v General
2142 - General
2143 - Privacy*
2144 v Project
2145 - Project Settings
2146 ",
2147 toggle_page: "General",
2148 after: r"
2149 > General*
2150 v Project
2151 - Project Settings
2152 "
2153 );
2154
2155 check_navbar_toggle!(
2156 navbar_basic_close,
2157 before: r"
2158 > General*
2159 - General
2160 - Privacy
2161 v Project
2162 - Project Settings
2163 ",
2164 toggle_page: "General",
2165 after: r"
2166 v General*
2167 - General
2168 - Privacy
2169 v Project
2170 - Project Settings
2171 "
2172 );
2173
2174 check_navbar_toggle!(
2175 navbar_basic_second_root_entry_close,
2176 before: r"
2177 > General
2178 - General
2179 - Privacy
2180 v Project
2181 - Project Settings*
2182 ",
2183 toggle_page: "Project",
2184 after: r"
2185 > General
2186 > Project*
2187 "
2188 );
2189
2190 check_navbar_toggle!(
2191 navbar_toggle_subroot,
2192 before: r"
2193 v General Page
2194 - General
2195 - Privacy
2196 v Project
2197 - Worktree Settings Content*
2198 v AI
2199 - General
2200 > Appearance & Behavior
2201 ",
2202 toggle_page: "Project",
2203 after: r"
2204 v General Page
2205 - General
2206 - Privacy
2207 > Project*
2208 v AI
2209 - General
2210 > Appearance & Behavior
2211 "
2212 );
2213
2214 check_navbar_toggle!(
2215 navbar_toggle_close_propagates_selected_index,
2216 before: r"
2217 v General Page
2218 - General
2219 - Privacy
2220 v Project
2221 - Worktree Settings Content
2222 v AI
2223 - General*
2224 > Appearance & Behavior
2225 ",
2226 toggle_page: "General Page",
2227 after: r"
2228 > General Page
2229 v Project
2230 - Worktree Settings Content
2231 v AI
2232 - General*
2233 > Appearance & Behavior
2234 "
2235 );
2236
2237 check_navbar_toggle!(
2238 navbar_toggle_expand_propagates_selected_index,
2239 before: r"
2240 > General Page
2241 - General
2242 - Privacy
2243 v Project
2244 - Worktree Settings Content
2245 v AI
2246 - General*
2247 > Appearance & Behavior
2248 ",
2249 toggle_page: "General Page",
2250 after: r"
2251 v General Page
2252 - General
2253 - Privacy
2254 v Project
2255 - Worktree Settings Content
2256 v AI
2257 - General*
2258 > Appearance & Behavior
2259 "
2260 );
2261
2262 #[gpui::test]
2263 fn test_basic_search(cx: &mut gpui::TestAppContext) {
2264 let cx = cx.add_empty_window();
2265 let (actual, expected) = cx.update(|window, cx| {
2266 register_settings(cx);
2267
2268 let expected = cx.new(|cx| {
2269 SettingsWindow::new_builder(window, cx)
2270 .add_page("General", |page| {
2271 page.item(SettingsPageItem::SectionHeader("General settings"))
2272 .item(SettingsPageItem::basic_item("test title", "General test"))
2273 })
2274 .build()
2275 });
2276
2277 let actual = cx.new(|cx| {
2278 SettingsWindow::new_builder(window, cx)
2279 .add_page("General", |page| {
2280 page.item(SettingsPageItem::SectionHeader("General settings"))
2281 .item(SettingsPageItem::basic_item("test title", "General test"))
2282 })
2283 .add_page("Theme", |page| {
2284 page.item(SettingsPageItem::SectionHeader("Theme settings"))
2285 })
2286 .build()
2287 });
2288
2289 actual.update(cx, |settings, cx| settings.search("gen", window, cx));
2290
2291 (actual, expected)
2292 });
2293
2294 cx.cx.run_until_parked();
2295
2296 cx.update(|_window, cx| {
2297 let expected = expected.read(cx);
2298 let actual = actual.read(cx);
2299 expected.assert_search_results(&actual);
2300 })
2301 }
2302
2303 #[gpui::test]
2304 fn test_search_render_page_with_filtered_out_navbar_entries(cx: &mut gpui::TestAppContext) {
2305 let cx = cx.add_empty_window();
2306 let (actual, expected) = cx.update(|window, cx| {
2307 register_settings(cx);
2308
2309 let actual = cx.new(|cx| {
2310 SettingsWindow::new_builder(window, cx)
2311 .add_page("General", |page| {
2312 page.item(SettingsPageItem::SectionHeader("General settings"))
2313 .item(SettingsPageItem::basic_item(
2314 "Confirm Quit",
2315 "Whether to confirm before quitting Zed",
2316 ))
2317 .item(SettingsPageItem::basic_item(
2318 "Auto Update",
2319 "Automatically update Zed",
2320 ))
2321 })
2322 .add_page("AI", |page| {
2323 page.item(SettingsPageItem::basic_item(
2324 "Disable AI",
2325 "Whether to disable all AI features in Zed",
2326 ))
2327 })
2328 .add_page("Appearance & Behavior", |page| {
2329 page.item(SettingsPageItem::SectionHeader("Cursor")).item(
2330 SettingsPageItem::basic_item(
2331 "Cursor Shape",
2332 "Cursor shape for the editor",
2333 ),
2334 )
2335 })
2336 .build()
2337 });
2338
2339 let expected = cx.new(|cx| {
2340 SettingsWindow::new_builder(window, cx)
2341 .add_page("Appearance & Behavior", |page| {
2342 page.item(SettingsPageItem::SectionHeader("Cursor")).item(
2343 SettingsPageItem::basic_item(
2344 "Cursor Shape",
2345 "Cursor shape for the editor",
2346 ),
2347 )
2348 })
2349 .build()
2350 });
2351
2352 actual.update(cx, |settings, cx| settings.search("cursor", window, cx));
2353
2354 (actual, expected)
2355 });
2356
2357 cx.cx.run_until_parked();
2358
2359 cx.update(|_window, cx| {
2360 let expected = expected.read(cx);
2361 let actual = actual.read(cx);
2362 expected.assert_search_results(&actual);
2363 })
2364 }
2365}