1mod components;
2mod page_data;
3mod pages;
4
5use anyhow::Result;
6use editor::{Editor, EditorEvent};
7use fuzzy::StringMatchCandidate;
8use gpui::{
9 Action, App, ClipboardItem, DEFAULT_ADDITIONAL_WINDOW_SIZE, Div, Entity, FocusHandle,
10 Focusable, Global, KeyContext, ListState, ReadGlobal as _, ScrollHandle, Stateful,
11 Subscription, Task, TitlebarOptions, UniformListScrollHandle, Window, WindowBounds,
12 WindowHandle, WindowOptions, actions, div, list, point, prelude::*, px, uniform_list,
13};
14use project::{Project, WorktreeId};
15use release_channel::ReleaseChannel;
16use schemars::JsonSchema;
17use serde::Deserialize;
18use settings::{Settings, SettingsContent, SettingsStore, initial_project_settings_content};
19use std::{
20 any::{Any, TypeId, type_name},
21 cell::RefCell,
22 collections::{HashMap, HashSet},
23 num::{NonZero, NonZeroU32},
24 ops::Range,
25 rc::Rc,
26 sync::{Arc, LazyLock, RwLock},
27 time::Duration,
28};
29use theme::ThemeSettings;
30use title_bar::platform_title_bar::PlatformTitleBar;
31use ui::{
32 Banner, ContextMenu, Divider, DropdownMenu, DropdownStyle, IconButtonShape, KeyBinding,
33 KeybindingHint, PopoverMenu, Switch, Tooltip, TreeViewItem, WithScrollbar, prelude::*,
34};
35use ui_input::{NumberField, NumberFieldType};
36use util::{ResultExt as _, paths::PathStyle, rel_path::RelPath};
37use workspace::{AppState, OpenOptions, OpenVisible, Workspace, client_side_decorations};
38use zed_actions::{OpenProjectSettings, OpenSettings, OpenSettingsAt};
39
40use crate::components::{
41 EnumVariantDropdown, SettingsInputField, SettingsSectionHeader, font_picker, icon_theme_picker,
42 theme_picker,
43};
44
45const NAVBAR_CONTAINER_TAB_INDEX: isize = 0;
46const NAVBAR_GROUP_TAB_INDEX: isize = 1;
47
48const HEADER_CONTAINER_TAB_INDEX: isize = 2;
49const HEADER_GROUP_TAB_INDEX: isize = 3;
50
51const CONTENT_CONTAINER_TAB_INDEX: isize = 4;
52const CONTENT_GROUP_TAB_INDEX: isize = 5;
53
54actions!(
55 settings_editor,
56 [
57 /// Minimizes the settings UI window.
58 Minimize,
59 /// Toggles focus between the navbar and the main content.
60 ToggleFocusNav,
61 /// Expands the navigation entry.
62 ExpandNavEntry,
63 /// Collapses the navigation entry.
64 CollapseNavEntry,
65 /// Focuses the next file in the file list.
66 FocusNextFile,
67 /// Focuses the previous file in the file list.
68 FocusPreviousFile,
69 /// Opens an editor for the current file
70 OpenCurrentFile,
71 /// Focuses the previous root navigation entry.
72 FocusPreviousRootNavEntry,
73 /// Focuses the next root navigation entry.
74 FocusNextRootNavEntry,
75 /// Focuses the first navigation entry.
76 FocusFirstNavEntry,
77 /// Focuses the last navigation entry.
78 FocusLastNavEntry,
79 /// Focuses and opens the next navigation entry without moving focus to content.
80 FocusNextNavEntry,
81 /// Focuses and opens the previous navigation entry without moving focus to content.
82 FocusPreviousNavEntry
83 ]
84);
85
86#[derive(Action, PartialEq, Eq, Clone, Copy, Debug, JsonSchema, Deserialize)]
87#[action(namespace = settings_editor)]
88struct FocusFile(pub u32);
89
90struct SettingField<T: 'static> {
91 pick: fn(&SettingsContent) -> Option<&T>,
92 write: fn(&mut SettingsContent, Option<T>),
93
94 /// A json-path-like string that gives a unique-ish string that identifies
95 /// where in the JSON the setting is defined.
96 ///
97 /// The syntax is `jq`-like, but modified slightly to be URL-safe (and
98 /// without the leading dot), e.g. `foo.bar`.
99 ///
100 /// They are URL-safe (this is important since links are the main use-case
101 /// for these paths).
102 ///
103 /// There are a couple of special cases:
104 /// - discrimminants are represented with a trailing `$`, for example
105 /// `terminal.working_directory$`. This is to distinguish the discrimminant
106 /// setting (i.e. the setting that changes whether the value is a string or
107 /// an object) from the setting in the case that it is a string.
108 /// - language-specific settings begin `languages.$(language)`. Links
109 /// targeting these settings should take the form `languages/Rust/...`, for
110 /// example, but are not currently supported.
111 json_path: Option<&'static str>,
112}
113
114impl<T: 'static> Clone for SettingField<T> {
115 fn clone(&self) -> Self {
116 *self
117 }
118}
119
120// manual impl because derive puts a Copy bound on T, which is inaccurate in our case
121impl<T: 'static> Copy for SettingField<T> {}
122
123/// Helper for unimplemented settings, used in combination with `SettingField::unimplemented`
124/// to keep the setting around in the UI with valid pick and write implementations, but don't actually try to render it.
125/// TODO(settings_ui): In non-dev builds (`#[cfg(not(debug_assertions))]`) make this render as edit-in-json
126#[derive(Clone, Copy)]
127struct UnimplementedSettingField;
128
129impl PartialEq for UnimplementedSettingField {
130 fn eq(&self, _other: &Self) -> bool {
131 true
132 }
133}
134
135impl<T: 'static> SettingField<T> {
136 /// Helper for settings with types that are not yet implemented.
137 #[allow(unused)]
138 fn unimplemented(self) -> SettingField<UnimplementedSettingField> {
139 SettingField {
140 pick: |_| Some(&UnimplementedSettingField),
141 write: |_, _| unreachable!(),
142 json_path: self.json_path,
143 }
144 }
145}
146
147trait AnySettingField {
148 fn as_any(&self) -> &dyn Any;
149 fn type_name(&self) -> &'static str;
150 fn type_id(&self) -> TypeId;
151 // 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)
152 fn file_set_in(&self, file: SettingsUiFile, cx: &App) -> (settings::SettingsFile, bool);
153 fn reset_to_default_fn(
154 &self,
155 current_file: &SettingsUiFile,
156 file_set_in: &settings::SettingsFile,
157 cx: &App,
158 ) -> Option<Box<dyn Fn(&mut App)>>;
159
160 fn json_path(&self) -> Option<&'static str>;
161}
162
163impl<T: PartialEq + Clone + Send + Sync + 'static> AnySettingField for SettingField<T> {
164 fn as_any(&self) -> &dyn Any {
165 self
166 }
167
168 fn type_name(&self) -> &'static str {
169 type_name::<T>()
170 }
171
172 fn type_id(&self) -> TypeId {
173 TypeId::of::<T>()
174 }
175
176 fn file_set_in(&self, file: SettingsUiFile, cx: &App) -> (settings::SettingsFile, bool) {
177 let (file, value) = cx
178 .global::<SettingsStore>()
179 .get_value_from_file(file.to_settings(), self.pick);
180 return (file, value.is_some());
181 }
182
183 fn reset_to_default_fn(
184 &self,
185 current_file: &SettingsUiFile,
186 file_set_in: &settings::SettingsFile,
187 cx: &App,
188 ) -> Option<Box<dyn Fn(&mut App)>> {
189 if file_set_in == &settings::SettingsFile::Default {
190 return None;
191 }
192 if file_set_in != ¤t_file.to_settings() {
193 return None;
194 }
195 let this = *self;
196 let store = SettingsStore::global(cx);
197 let default_value = (this.pick)(store.raw_default_settings());
198 let is_default = store
199 .get_content_for_file(file_set_in.clone())
200 .map_or(None, this.pick)
201 == default_value;
202 if is_default {
203 return None;
204 }
205 let current_file = current_file.clone();
206
207 return Some(Box::new(move |cx| {
208 let store = SettingsStore::global(cx);
209 let default_value = (this.pick)(store.raw_default_settings());
210 let is_set_somewhere_other_than_default = store
211 .get_value_up_to_file(current_file.to_settings(), this.pick)
212 .0
213 != settings::SettingsFile::Default;
214 let value_to_set = if is_set_somewhere_other_than_default {
215 default_value.cloned()
216 } else {
217 None
218 };
219 update_settings_file(current_file.clone(), None, cx, move |settings, _| {
220 (this.write)(settings, value_to_set);
221 })
222 // todo(settings_ui): Don't log err
223 .log_err();
224 }));
225 }
226
227 fn json_path(&self) -> Option<&'static str> {
228 self.json_path
229 }
230}
231
232#[derive(Default, Clone)]
233struct SettingFieldRenderer {
234 renderers: Rc<
235 RefCell<
236 HashMap<
237 TypeId,
238 Box<
239 dyn Fn(
240 &SettingsWindow,
241 &SettingItem,
242 SettingsUiFile,
243 Option<&SettingsFieldMetadata>,
244 bool,
245 &mut Window,
246 &mut Context<SettingsWindow>,
247 ) -> Stateful<Div>,
248 >,
249 >,
250 >,
251 >,
252}
253
254impl Global for SettingFieldRenderer {}
255
256impl SettingFieldRenderer {
257 fn add_basic_renderer<T: 'static>(
258 &mut self,
259 render_control: impl Fn(
260 SettingField<T>,
261 SettingsUiFile,
262 Option<&SettingsFieldMetadata>,
263 &mut Window,
264 &mut App,
265 ) -> AnyElement
266 + 'static,
267 ) -> &mut Self {
268 self.add_renderer(
269 move |settings_window: &SettingsWindow,
270 item: &SettingItem,
271 field: SettingField<T>,
272 settings_file: SettingsUiFile,
273 metadata: Option<&SettingsFieldMetadata>,
274 sub_field: bool,
275 window: &mut Window,
276 cx: &mut Context<SettingsWindow>| {
277 render_settings_item(
278 settings_window,
279 item,
280 settings_file.clone(),
281 render_control(field, settings_file, metadata, window, cx),
282 sub_field,
283 cx,
284 )
285 },
286 )
287 }
288
289 fn add_renderer<T: 'static>(
290 &mut self,
291 renderer: impl Fn(
292 &SettingsWindow,
293 &SettingItem,
294 SettingField<T>,
295 SettingsUiFile,
296 Option<&SettingsFieldMetadata>,
297 bool,
298 &mut Window,
299 &mut Context<SettingsWindow>,
300 ) -> Stateful<Div>
301 + 'static,
302 ) -> &mut Self {
303 let key = TypeId::of::<T>();
304 let renderer = Box::new(
305 move |settings_window: &SettingsWindow,
306 item: &SettingItem,
307 settings_file: SettingsUiFile,
308 metadata: Option<&SettingsFieldMetadata>,
309 sub_field: bool,
310 window: &mut Window,
311 cx: &mut Context<SettingsWindow>| {
312 let field = *item
313 .field
314 .as_ref()
315 .as_any()
316 .downcast_ref::<SettingField<T>>()
317 .unwrap();
318 renderer(
319 settings_window,
320 item,
321 field,
322 settings_file,
323 metadata,
324 sub_field,
325 window,
326 cx,
327 )
328 },
329 );
330 self.renderers.borrow_mut().insert(key, renderer);
331 self
332 }
333}
334
335struct NonFocusableHandle {
336 handle: FocusHandle,
337 _subscription: Subscription,
338}
339
340impl NonFocusableHandle {
341 fn new(tab_index: isize, tab_stop: bool, window: &mut Window, cx: &mut App) -> Entity<Self> {
342 let handle = cx.focus_handle().tab_index(tab_index).tab_stop(tab_stop);
343 Self::from_handle(handle, window, cx)
344 }
345
346 fn from_handle(handle: FocusHandle, window: &mut Window, cx: &mut App) -> Entity<Self> {
347 cx.new(|cx| {
348 let _subscription = cx.on_focus(&handle, window, {
349 move |_, window, cx| {
350 window.focus_next(cx);
351 }
352 });
353 Self {
354 handle,
355 _subscription,
356 }
357 })
358 }
359}
360
361impl Focusable for NonFocusableHandle {
362 fn focus_handle(&self, _: &App) -> FocusHandle {
363 self.handle.clone()
364 }
365}
366
367#[derive(Default)]
368struct SettingsFieldMetadata {
369 placeholder: Option<&'static str>,
370 should_do_titlecase: Option<bool>,
371}
372
373pub fn init(cx: &mut App) {
374 init_renderers(cx);
375
376 cx.observe_new(|workspace: &mut workspace::Workspace, _, _| {
377 workspace
378 .register_action(
379 |workspace, OpenSettingsAt { path }: &OpenSettingsAt, window, cx| {
380 let window_handle = window
381 .window_handle()
382 .downcast::<Workspace>()
383 .expect("Workspaces are root Windows");
384 open_settings_editor(workspace, Some(&path), false, window_handle, cx);
385 },
386 )
387 .register_action(|workspace, _: &OpenSettings, window, cx| {
388 let window_handle = window
389 .window_handle()
390 .downcast::<Workspace>()
391 .expect("Workspaces are root Windows");
392 open_settings_editor(workspace, None, false, window_handle, cx);
393 })
394 .register_action(|workspace, _: &OpenProjectSettings, window, cx| {
395 let window_handle = window
396 .window_handle()
397 .downcast::<Workspace>()
398 .expect("Workspaces are root Windows");
399 open_settings_editor(workspace, None, true, window_handle, cx);
400 });
401 })
402 .detach();
403}
404
405fn init_renderers(cx: &mut App) {
406 cx.default_global::<SettingFieldRenderer>()
407 .add_renderer::<UnimplementedSettingField>(
408 |settings_window, item, _, settings_file, _, sub_field, _, cx| {
409 render_settings_item(
410 settings_window,
411 item,
412 settings_file,
413 Button::new("open-in-settings-file", "Edit in settings.json")
414 .style(ButtonStyle::Outlined)
415 .size(ButtonSize::Medium)
416 .tab_index(0_isize)
417 .tooltip(Tooltip::for_action_title_in(
418 "Edit in settings.json",
419 &OpenCurrentFile,
420 &settings_window.focus_handle,
421 ))
422 .on_click(cx.listener(|this, _, window, cx| {
423 this.open_current_settings_file(window, cx);
424 }))
425 .into_any_element(),
426 sub_field,
427 cx,
428 )
429 },
430 )
431 .add_basic_renderer::<bool>(render_toggle_button)
432 .add_basic_renderer::<String>(render_text_field)
433 .add_basic_renderer::<SharedString>(render_text_field)
434 .add_basic_renderer::<settings::SaturatingBool>(render_toggle_button)
435 .add_basic_renderer::<settings::CursorShape>(render_dropdown)
436 .add_basic_renderer::<settings::RestoreOnStartupBehavior>(render_dropdown)
437 .add_basic_renderer::<settings::BottomDockLayout>(render_dropdown)
438 .add_basic_renderer::<settings::OnLastWindowClosed>(render_dropdown)
439 .add_basic_renderer::<settings::CloseWindowWhenNoItems>(render_dropdown)
440 .add_basic_renderer::<settings::FontFamilyName>(render_font_picker)
441 .add_basic_renderer::<settings::BaseKeymapContent>(render_dropdown)
442 .add_basic_renderer::<settings::MultiCursorModifier>(render_dropdown)
443 .add_basic_renderer::<settings::HideMouseMode>(render_dropdown)
444 .add_basic_renderer::<settings::CurrentLineHighlight>(render_dropdown)
445 .add_basic_renderer::<settings::ShowWhitespaceSetting>(render_dropdown)
446 .add_basic_renderer::<settings::SoftWrap>(render_dropdown)
447 .add_basic_renderer::<settings::ScrollBeyondLastLine>(render_dropdown)
448 .add_basic_renderer::<settings::SnippetSortOrder>(render_dropdown)
449 .add_basic_renderer::<settings::ClosePosition>(render_dropdown)
450 .add_basic_renderer::<settings::DockSide>(render_dropdown)
451 .add_basic_renderer::<settings::TerminalDockPosition>(render_dropdown)
452 .add_basic_renderer::<settings::DockPosition>(render_dropdown)
453 .add_basic_renderer::<settings::GitGutterSetting>(render_dropdown)
454 .add_basic_renderer::<settings::GitHunkStyleSetting>(render_dropdown)
455 .add_basic_renderer::<settings::GitPathStyle>(render_dropdown)
456 .add_basic_renderer::<settings::DiagnosticSeverityContent>(render_dropdown)
457 .add_basic_renderer::<settings::SeedQuerySetting>(render_dropdown)
458 .add_basic_renderer::<settings::DoubleClickInMultibuffer>(render_dropdown)
459 .add_basic_renderer::<settings::GoToDefinitionFallback>(render_dropdown)
460 .add_basic_renderer::<settings::ActivateOnClose>(render_dropdown)
461 .add_basic_renderer::<settings::ShowDiagnostics>(render_dropdown)
462 .add_basic_renderer::<settings::ShowCloseButton>(render_dropdown)
463 .add_basic_renderer::<settings::ProjectPanelEntrySpacing>(render_dropdown)
464 .add_basic_renderer::<settings::ProjectPanelSortMode>(render_dropdown)
465 .add_basic_renderer::<settings::RewrapBehavior>(render_dropdown)
466 .add_basic_renderer::<settings::FormatOnSave>(render_dropdown)
467 .add_basic_renderer::<settings::IndentGuideColoring>(render_dropdown)
468 .add_basic_renderer::<settings::IndentGuideBackgroundColoring>(render_dropdown)
469 .add_basic_renderer::<settings::FileFinderWidthContent>(render_dropdown)
470 .add_basic_renderer::<settings::ShowDiagnostics>(render_dropdown)
471 .add_basic_renderer::<settings::WordsCompletionMode>(render_dropdown)
472 .add_basic_renderer::<settings::LspInsertMode>(render_dropdown)
473 .add_basic_renderer::<settings::AlternateScroll>(render_dropdown)
474 .add_basic_renderer::<settings::TerminalBlink>(render_dropdown)
475 .add_basic_renderer::<settings::CursorShapeContent>(render_dropdown)
476 .add_basic_renderer::<f32>(render_number_field)
477 .add_basic_renderer::<u32>(render_number_field)
478 .add_basic_renderer::<u64>(render_number_field)
479 .add_basic_renderer::<usize>(render_number_field)
480 .add_basic_renderer::<NonZero<usize>>(render_number_field)
481 .add_basic_renderer::<NonZeroU32>(render_number_field)
482 .add_basic_renderer::<settings::CodeFade>(render_number_field)
483 .add_basic_renderer::<settings::DelayMs>(render_number_field)
484 .add_basic_renderer::<gpui::FontWeight>(render_number_field)
485 .add_basic_renderer::<settings::CenteredPaddingSettings>(render_number_field)
486 .add_basic_renderer::<settings::InactiveOpacity>(render_number_field)
487 .add_basic_renderer::<settings::MinimumContrast>(render_number_field)
488 .add_basic_renderer::<settings::ShowScrollbar>(render_dropdown)
489 .add_basic_renderer::<settings::ScrollbarDiagnostics>(render_dropdown)
490 .add_basic_renderer::<settings::ShowMinimap>(render_dropdown)
491 .add_basic_renderer::<settings::DisplayIn>(render_dropdown)
492 .add_basic_renderer::<settings::MinimapThumb>(render_dropdown)
493 .add_basic_renderer::<settings::MinimapThumbBorder>(render_dropdown)
494 .add_basic_renderer::<settings::SteppingGranularity>(render_dropdown)
495 .add_basic_renderer::<settings::NotifyWhenAgentWaiting>(render_dropdown)
496 .add_basic_renderer::<settings::NotifyWhenAgentWaiting>(render_dropdown)
497 .add_basic_renderer::<settings::ImageFileSizeUnit>(render_dropdown)
498 .add_basic_renderer::<settings::StatusStyle>(render_dropdown)
499 .add_basic_renderer::<settings::PaneSplitDirectionHorizontal>(render_dropdown)
500 .add_basic_renderer::<settings::PaneSplitDirectionVertical>(render_dropdown)
501 .add_basic_renderer::<settings::PaneSplitDirectionVertical>(render_dropdown)
502 .add_basic_renderer::<settings::DocumentColorsRenderMode>(render_dropdown)
503 .add_basic_renderer::<settings::ThemeSelectionDiscriminants>(render_dropdown)
504 .add_basic_renderer::<settings::ThemeAppearanceMode>(render_dropdown)
505 .add_basic_renderer::<settings::ThemeName>(render_theme_picker)
506 .add_basic_renderer::<settings::IconThemeSelectionDiscriminants>(render_dropdown)
507 .add_basic_renderer::<settings::IconThemeName>(render_icon_theme_picker)
508 .add_basic_renderer::<settings::BufferLineHeightDiscriminants>(render_dropdown)
509 .add_basic_renderer::<settings::AutosaveSettingDiscriminants>(render_dropdown)
510 .add_basic_renderer::<settings::WorkingDirectoryDiscriminants>(render_dropdown)
511 .add_basic_renderer::<settings::IncludeIgnoredContent>(render_dropdown)
512 .add_basic_renderer::<settings::ShowIndentGuides>(render_dropdown)
513 .add_basic_renderer::<settings::ShellDiscriminants>(render_dropdown)
514 .add_basic_renderer::<settings::EditPredictionsMode>(render_dropdown)
515 .add_basic_renderer::<settings::RelativeLineNumbers>(render_dropdown)
516 .add_basic_renderer::<settings::WindowDecorations>(render_dropdown)
517 // please semicolon stay on next line
518 ;
519}
520
521pub fn open_settings_editor(
522 _workspace: &mut Workspace,
523 path: Option<&str>,
524 open_project_settings: bool,
525 workspace_handle: WindowHandle<Workspace>,
526 cx: &mut App,
527) {
528 telemetry::event!("Settings Viewed");
529
530 /// Assumes a settings GUI window is already open
531 fn open_path(
532 path: &str,
533 // Note: This option is unsupported right now
534 _open_project_settings: bool,
535 settings_window: &mut SettingsWindow,
536 window: &mut Window,
537 cx: &mut Context<SettingsWindow>,
538 ) {
539 if path.starts_with("languages.$(language)") {
540 log::error!("language-specific settings links are not currently supported");
541 return;
542 }
543
544 settings_window.search_bar.update(cx, |editor, cx| {
545 editor.set_text(format!("#{path}"), window, cx);
546 });
547 settings_window.update_matches(cx);
548 }
549
550 let existing_window = cx
551 .windows()
552 .into_iter()
553 .find_map(|window| window.downcast::<SettingsWindow>());
554
555 if let Some(existing_window) = existing_window {
556 existing_window
557 .update(cx, |settings_window, window, cx| {
558 settings_window.original_window = Some(workspace_handle);
559 window.activate_window();
560 if let Some(path) = path {
561 open_path(path, open_project_settings, settings_window, window, cx);
562 } else if open_project_settings {
563 if let Some(file_index) = settings_window
564 .files
565 .iter()
566 .position(|(file, _)| file.worktree_id().is_some())
567 {
568 settings_window.change_file(file_index, window, cx);
569 }
570
571 cx.notify();
572 }
573 })
574 .ok();
575 return;
576 }
577
578 // We have to defer this to get the workspace off the stack.
579
580 let path = path.map(ToOwned::to_owned);
581 cx.defer(move |cx| {
582 let current_rem_size: f32 = theme::ThemeSettings::get_global(cx).ui_font_size(cx).into();
583
584 let default_bounds = DEFAULT_ADDITIONAL_WINDOW_SIZE;
585 let default_rem_size = 16.0;
586 let scale_factor = current_rem_size / default_rem_size;
587 let scaled_bounds: gpui::Size<Pixels> = default_bounds.map(|axis| axis * scale_factor);
588
589 let app_id = ReleaseChannel::global(cx).app_id();
590 let window_decorations = match std::env::var("ZED_WINDOW_DECORATIONS") {
591 Ok(val) if val == "server" => gpui::WindowDecorations::Server,
592 Ok(val) if val == "client" => gpui::WindowDecorations::Client,
593 _ => gpui::WindowDecorations::Client,
594 };
595
596 cx.open_window(
597 WindowOptions {
598 titlebar: Some(TitlebarOptions {
599 title: Some("Zed — Settings".into()),
600 appears_transparent: true,
601 traffic_light_position: Some(point(px(12.0), px(12.0))),
602 }),
603 focus: true,
604 show: true,
605 is_movable: true,
606 kind: gpui::WindowKind::Floating,
607 window_background: cx.theme().window_background_appearance(),
608 app_id: Some(app_id.to_owned()),
609 window_decorations: Some(window_decorations),
610 window_min_size: Some(gpui::Size {
611 // Don't make the settings window thinner than this,
612 // otherwise, it gets unusable. Users with smaller res monitors
613 // can customize the height, but not the width.
614 width: px(900.0),
615 height: px(240.0),
616 }),
617 window_bounds: Some(WindowBounds::centered(scaled_bounds, cx)),
618 ..Default::default()
619 },
620 |window, cx| {
621 let settings_window =
622 cx.new(|cx| SettingsWindow::new(Some(workspace_handle), window, cx));
623 settings_window.update(cx, |settings_window, cx| {
624 if let Some(path) = path {
625 open_path(&path, open_project_settings, settings_window, window, cx);
626 } else if open_project_settings {
627 if let Some(file_index) = settings_window
628 .files
629 .iter()
630 .position(|(file, _)| file.worktree_id().is_some())
631 {
632 settings_window.change_file(file_index, window, cx);
633 }
634
635 settings_window.fetch_files(window, cx);
636 }
637 });
638
639 settings_window
640 },
641 )
642 .log_err();
643 });
644}
645
646/// The current sub page path that is selected.
647/// If this is empty the selected page is rendered,
648/// otherwise the last sub page gets rendered.
649///
650/// Global so that `pick` and `write` callbacks can access it
651/// and use it to dynamically render sub pages (e.g. for language settings)
652static SUB_PAGE_STACK: LazyLock<RwLock<Vec<SubPage>>> = LazyLock::new(|| RwLock::new(Vec::new()));
653
654fn sub_page_stack() -> std::sync::RwLockReadGuard<'static, Vec<SubPage>> {
655 SUB_PAGE_STACK
656 .read()
657 .expect("SUB_PAGE_STACK is never poisoned")
658}
659
660fn sub_page_stack_mut() -> std::sync::RwLockWriteGuard<'static, Vec<SubPage>> {
661 SUB_PAGE_STACK
662 .write()
663 .expect("SUB_PAGE_STACK is never poisoned")
664}
665
666pub struct SettingsWindow {
667 title_bar: Option<Entity<PlatformTitleBar>>,
668 original_window: Option<WindowHandle<Workspace>>,
669 files: Vec<(SettingsUiFile, FocusHandle)>,
670 worktree_root_dirs: HashMap<WorktreeId, String>,
671 current_file: SettingsUiFile,
672 pages: Vec<SettingsPage>,
673 search_bar: Entity<Editor>,
674 search_task: Option<Task<()>>,
675 /// Index into navbar_entries
676 navbar_entry: usize,
677 navbar_entries: Vec<NavBarEntry>,
678 navbar_scroll_handle: UniformListScrollHandle,
679 /// [page_index][page_item_index] will be false
680 /// when the item is filtered out either by searches
681 /// or by the current file
682 navbar_focus_subscriptions: Vec<gpui::Subscription>,
683 filter_table: Vec<Vec<bool>>,
684 has_query: bool,
685 content_handles: Vec<Vec<Entity<NonFocusableHandle>>>,
686 sub_page_scroll_handle: ScrollHandle,
687 focus_handle: FocusHandle,
688 navbar_focus_handle: Entity<NonFocusableHandle>,
689 content_focus_handle: Entity<NonFocusableHandle>,
690 files_focus_handle: FocusHandle,
691 search_index: Option<Arc<SearchIndex>>,
692 list_state: ListState,
693 shown_errors: HashSet<String>,
694}
695
696struct SearchIndex {
697 bm25_engine: bm25::SearchEngine<usize>,
698 fuzzy_match_candidates: Vec<StringMatchCandidate>,
699 key_lut: Vec<SearchKeyLUTEntry>,
700}
701
702struct SearchKeyLUTEntry {
703 page_index: usize,
704 header_index: usize,
705 item_index: usize,
706 json_path: Option<&'static str>,
707}
708
709struct SubPage {
710 link: SubPageLink,
711 section_header: &'static str,
712}
713
714#[derive(Debug)]
715struct NavBarEntry {
716 title: &'static str,
717 is_root: bool,
718 expanded: bool,
719 page_index: usize,
720 item_index: Option<usize>,
721 focus_handle: FocusHandle,
722}
723
724struct SettingsPage {
725 title: &'static str,
726 items: Vec<SettingsPageItem>,
727}
728
729#[derive(PartialEq)]
730enum SettingsPageItem {
731 SectionHeader(&'static str),
732 SettingItem(SettingItem),
733 SubPageLink(SubPageLink),
734 DynamicItem(DynamicItem),
735 ActionLink(ActionLink),
736}
737
738impl std::fmt::Debug for SettingsPageItem {
739 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
740 match self {
741 SettingsPageItem::SectionHeader(header) => write!(f, "SectionHeader({})", header),
742 SettingsPageItem::SettingItem(setting_item) => {
743 write!(f, "SettingItem({})", setting_item.title)
744 }
745 SettingsPageItem::SubPageLink(sub_page_link) => {
746 write!(f, "SubPageLink({})", sub_page_link.title)
747 }
748 SettingsPageItem::DynamicItem(dynamic_item) => {
749 write!(f, "DynamicItem({})", dynamic_item.discriminant.title)
750 }
751 SettingsPageItem::ActionLink(action_link) => {
752 write!(f, "ActionLink({})", action_link.title)
753 }
754 }
755 }
756}
757
758impl SettingsPageItem {
759 fn render(
760 &self,
761 settings_window: &SettingsWindow,
762 item_index: usize,
763 is_last: bool,
764 window: &mut Window,
765 cx: &mut Context<SettingsWindow>,
766 ) -> AnyElement {
767 let file = settings_window.current_file.clone();
768
769 let apply_padding = |element: Stateful<Div>| -> Stateful<Div> {
770 let element = element.pt_4();
771 if is_last {
772 element.pb_10()
773 } else {
774 element.pb_4()
775 }
776 };
777
778 let mut render_setting_item_inner =
779 |setting_item: &SettingItem,
780 padding: bool,
781 sub_field: bool,
782 cx: &mut Context<SettingsWindow>| {
783 let renderer = cx.default_global::<SettingFieldRenderer>().clone();
784 let (_, found) = setting_item.field.file_set_in(file.clone(), cx);
785
786 let renderers = renderer.renderers.borrow();
787
788 let field_renderer =
789 renderers.get(&AnySettingField::type_id(setting_item.field.as_ref()));
790 let field_renderer_or_warning =
791 field_renderer.ok_or("NO RENDERER").and_then(|renderer| {
792 if cfg!(debug_assertions) && !found {
793 Err("NO DEFAULT")
794 } else {
795 Ok(renderer)
796 }
797 });
798
799 let field = match field_renderer_or_warning {
800 Ok(field_renderer) => window.with_id(item_index, |window| {
801 field_renderer(
802 settings_window,
803 setting_item,
804 file.clone(),
805 setting_item.metadata.as_deref(),
806 sub_field,
807 window,
808 cx,
809 )
810 }),
811 Err(warning) => render_settings_item(
812 settings_window,
813 setting_item,
814 file.clone(),
815 Button::new("error-warning", warning)
816 .style(ButtonStyle::Outlined)
817 .size(ButtonSize::Medium)
818 .icon(Some(IconName::Debug))
819 .icon_position(IconPosition::Start)
820 .icon_color(Color::Error)
821 .tab_index(0_isize)
822 .tooltip(Tooltip::text(setting_item.field.type_name()))
823 .into_any_element(),
824 sub_field,
825 cx,
826 ),
827 };
828
829 let field = if padding {
830 field.map(apply_padding)
831 } else {
832 field
833 };
834
835 (field, field_renderer_or_warning.is_ok())
836 };
837
838 match self {
839 SettingsPageItem::SectionHeader(header) => {
840 SettingsSectionHeader::new(SharedString::new_static(header)).into_any_element()
841 }
842 SettingsPageItem::SettingItem(setting_item) => {
843 let (field_with_padding, _) =
844 render_setting_item_inner(setting_item, true, false, cx);
845
846 v_flex()
847 .group("setting-item")
848 .px_8()
849 .child(field_with_padding)
850 .when(!is_last, |this| this.child(Divider::horizontal()))
851 .into_any_element()
852 }
853 SettingsPageItem::SubPageLink(sub_page_link) => v_flex()
854 .group("setting-item")
855 .px_8()
856 .child(
857 h_flex()
858 .id(sub_page_link.title.clone())
859 .w_full()
860 .min_w_0()
861 .justify_between()
862 .map(apply_padding)
863 .child(
864 v_flex()
865 .relative()
866 .w_full()
867 .max_w_1_2()
868 .child(Label::new(sub_page_link.title.clone()))
869 .when_some(
870 sub_page_link.description.as_ref(),
871 |this, description| {
872 this.child(
873 Label::new(description.clone())
874 .size(LabelSize::Small)
875 .color(Color::Muted),
876 )
877 },
878 ),
879 )
880 .child(
881 Button::new(
882 ("sub-page".into(), sub_page_link.title.clone()),
883 "Configure",
884 )
885 .icon(IconName::ChevronRight)
886 .tab_index(0_isize)
887 .icon_position(IconPosition::End)
888 .icon_color(Color::Muted)
889 .icon_size(IconSize::Small)
890 .style(ButtonStyle::OutlinedGhost)
891 .size(ButtonSize::Medium)
892 .on_click({
893 let sub_page_link = sub_page_link.clone();
894 cx.listener(move |this, _, window, cx| {
895 let mut section_index = item_index;
896 let current_page = this.current_page();
897
898 while !matches!(
899 current_page.items[section_index],
900 SettingsPageItem::SectionHeader(_)
901 ) {
902 section_index -= 1;
903 }
904
905 let SettingsPageItem::SectionHeader(header) =
906 current_page.items[section_index]
907 else {
908 unreachable!(
909 "All items always have a section header above them"
910 )
911 };
912
913 this.push_sub_page(sub_page_link.clone(), header, window, cx)
914 })
915 }),
916 )
917 .child(render_settings_item_link(
918 sub_page_link.title.clone(),
919 sub_page_link.json_path,
920 false,
921 cx,
922 )),
923 )
924 .when(!is_last, |this| this.child(Divider::horizontal()))
925 .into_any_element(),
926 SettingsPageItem::DynamicItem(DynamicItem {
927 discriminant: discriminant_setting_item,
928 pick_discriminant,
929 fields,
930 }) => {
931 let file = file.to_settings();
932 let discriminant = SettingsStore::global(cx)
933 .get_value_from_file(file, *pick_discriminant)
934 .1;
935
936 let (discriminant_element, rendered_ok) =
937 render_setting_item_inner(discriminant_setting_item, true, false, cx);
938
939 let has_sub_fields =
940 rendered_ok && discriminant.map(|d| !fields[d].is_empty()).unwrap_or(false);
941
942 let mut content = v_flex()
943 .id("dynamic-item")
944 .child(
945 div()
946 .group("setting-item")
947 .px_8()
948 .child(discriminant_element.when(has_sub_fields, |this| this.pb_4())),
949 )
950 .when(!has_sub_fields && !is_last, |this| {
951 this.child(h_flex().px_8().child(Divider::horizontal()))
952 });
953
954 if rendered_ok {
955 let discriminant =
956 discriminant.expect("This should be Some if rendered_ok is true");
957 let sub_fields = &fields[discriminant];
958 let sub_field_count = sub_fields.len();
959
960 for (index, field) in sub_fields.iter().enumerate() {
961 let is_last_sub_field = index == sub_field_count - 1;
962 let (raw_field, _) = render_setting_item_inner(field, false, true, cx);
963
964 content = content.child(
965 raw_field
966 .group("setting-sub-item")
967 .mx_8()
968 .p_4()
969 .border_t_1()
970 .when(is_last_sub_field, |this| this.border_b_1())
971 .when(is_last_sub_field && is_last, |this| this.mb_8())
972 .border_dashed()
973 .border_color(cx.theme().colors().border_variant)
974 .bg(cx.theme().colors().element_background.opacity(0.2)),
975 );
976 }
977 }
978
979 return content.into_any_element();
980 }
981 SettingsPageItem::ActionLink(action_link) => v_flex()
982 .group("setting-item")
983 .px_8()
984 .child(
985 h_flex()
986 .id(action_link.title.clone())
987 .w_full()
988 .min_w_0()
989 .justify_between()
990 .map(apply_padding)
991 .child(
992 v_flex()
993 .relative()
994 .w_full()
995 .max_w_1_2()
996 .child(Label::new(action_link.title.clone()))
997 .when_some(
998 action_link.description.as_ref(),
999 |this, description| {
1000 this.child(
1001 Label::new(description.clone())
1002 .size(LabelSize::Small)
1003 .color(Color::Muted),
1004 )
1005 },
1006 ),
1007 )
1008 .child(
1009 Button::new(
1010 ("action-link".into(), action_link.title.clone()),
1011 action_link.button_text.clone(),
1012 )
1013 .icon(IconName::ArrowUpRight)
1014 .tab_index(0_isize)
1015 .icon_position(IconPosition::End)
1016 .icon_color(Color::Muted)
1017 .icon_size(IconSize::Small)
1018 .style(ButtonStyle::OutlinedGhost)
1019 .size(ButtonSize::Medium)
1020 .on_click({
1021 let on_click = action_link.on_click.clone();
1022 cx.listener(move |this, _, window, cx| {
1023 on_click(this, window, cx);
1024 })
1025 }),
1026 ),
1027 )
1028 .when(!is_last, |this| this.child(Divider::horizontal()))
1029 .into_any_element(),
1030 }
1031 }
1032}
1033
1034fn render_settings_item(
1035 settings_window: &SettingsWindow,
1036 setting_item: &SettingItem,
1037 file: SettingsUiFile,
1038 control: AnyElement,
1039 sub_field: bool,
1040 cx: &mut Context<'_, SettingsWindow>,
1041) -> Stateful<Div> {
1042 let (found_in_file, _) = setting_item.field.file_set_in(file.clone(), cx);
1043 let file_set_in = SettingsUiFile::from_settings(found_in_file.clone());
1044
1045 h_flex()
1046 .id(setting_item.title)
1047 .min_w_0()
1048 .justify_between()
1049 .child(
1050 v_flex()
1051 .relative()
1052 .w_1_2()
1053 .child(
1054 h_flex()
1055 .w_full()
1056 .gap_1()
1057 .child(Label::new(SharedString::new_static(setting_item.title)))
1058 .when_some(
1059 if sub_field {
1060 None
1061 } else {
1062 setting_item
1063 .field
1064 .reset_to_default_fn(&file, &found_in_file, cx)
1065 },
1066 |this, reset_to_default| {
1067 this.child(
1068 IconButton::new("reset-to-default-btn", IconName::Undo)
1069 .icon_color(Color::Muted)
1070 .icon_size(IconSize::Small)
1071 .tooltip(Tooltip::text("Reset to Default"))
1072 .on_click({
1073 move |_, _, cx| {
1074 reset_to_default(cx);
1075 }
1076 }),
1077 )
1078 },
1079 )
1080 .when_some(
1081 file_set_in.filter(|file_set_in| file_set_in != &file),
1082 |this, file_set_in| {
1083 this.child(
1084 Label::new(format!(
1085 "— Modified in {}",
1086 settings_window
1087 .display_name(&file_set_in)
1088 .expect("File name should exist")
1089 ))
1090 .color(Color::Muted)
1091 .size(LabelSize::Small),
1092 )
1093 },
1094 ),
1095 )
1096 .child(
1097 Label::new(SharedString::new_static(setting_item.description))
1098 .size(LabelSize::Small)
1099 .color(Color::Muted),
1100 ),
1101 )
1102 .child(control)
1103 .when(sub_page_stack().is_empty(), |this| {
1104 this.child(render_settings_item_link(
1105 setting_item.description,
1106 setting_item.field.json_path(),
1107 sub_field,
1108 cx,
1109 ))
1110 })
1111}
1112
1113fn render_settings_item_link(
1114 id: impl Into<ElementId>,
1115 json_path: Option<&'static str>,
1116 sub_field: bool,
1117 cx: &mut Context<'_, SettingsWindow>,
1118) -> impl IntoElement {
1119 let clipboard_has_link = cx
1120 .read_from_clipboard()
1121 .and_then(|entry| entry.text())
1122 .map_or(false, |maybe_url| {
1123 json_path.is_some() && maybe_url.strip_prefix("zed://settings/") == json_path
1124 });
1125
1126 let (link_icon, link_icon_color) = if clipboard_has_link {
1127 (IconName::Check, Color::Success)
1128 } else {
1129 (IconName::Link, Color::Muted)
1130 };
1131
1132 div()
1133 .absolute()
1134 .top(rems_from_px(18.))
1135 .map(|this| {
1136 if sub_field {
1137 this.visible_on_hover("setting-sub-item")
1138 .left(rems_from_px(-8.5))
1139 } else {
1140 this.visible_on_hover("setting-item")
1141 .left(rems_from_px(-22.))
1142 }
1143 })
1144 .child(
1145 IconButton::new((id.into(), "copy-link-btn"), link_icon)
1146 .icon_color(link_icon_color)
1147 .icon_size(IconSize::Small)
1148 .shape(IconButtonShape::Square)
1149 .tooltip(Tooltip::text("Copy Link"))
1150 .when_some(json_path, |this, path| {
1151 this.on_click(cx.listener(move |_, _, _, cx| {
1152 let link = format!("zed://settings/{}", path);
1153 cx.write_to_clipboard(ClipboardItem::new_string(link));
1154 cx.notify();
1155 }))
1156 }),
1157 )
1158}
1159
1160struct SettingItem {
1161 title: &'static str,
1162 description: &'static str,
1163 field: Box<dyn AnySettingField>,
1164 metadata: Option<Box<SettingsFieldMetadata>>,
1165 files: FileMask,
1166}
1167
1168struct DynamicItem {
1169 discriminant: SettingItem,
1170 pick_discriminant: fn(&SettingsContent) -> Option<usize>,
1171 fields: Vec<Vec<SettingItem>>,
1172}
1173
1174impl PartialEq for DynamicItem {
1175 fn eq(&self, other: &Self) -> bool {
1176 self.discriminant == other.discriminant && self.fields == other.fields
1177 }
1178}
1179
1180#[derive(PartialEq, Eq, Clone, Copy)]
1181struct FileMask(u8);
1182
1183impl std::fmt::Debug for FileMask {
1184 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1185 write!(f, "FileMask(")?;
1186 let mut items = vec![];
1187
1188 if self.contains(USER) {
1189 items.push("USER");
1190 }
1191 if self.contains(PROJECT) {
1192 items.push("LOCAL");
1193 }
1194 if self.contains(SERVER) {
1195 items.push("SERVER");
1196 }
1197
1198 write!(f, "{})", items.join(" | "))
1199 }
1200}
1201
1202const USER: FileMask = FileMask(1 << 0);
1203const PROJECT: FileMask = FileMask(1 << 2);
1204const SERVER: FileMask = FileMask(1 << 3);
1205
1206impl std::ops::BitAnd for FileMask {
1207 type Output = Self;
1208
1209 fn bitand(self, other: Self) -> Self {
1210 Self(self.0 & other.0)
1211 }
1212}
1213
1214impl std::ops::BitOr for FileMask {
1215 type Output = Self;
1216
1217 fn bitor(self, other: Self) -> Self {
1218 Self(self.0 | other.0)
1219 }
1220}
1221
1222impl FileMask {
1223 fn contains(&self, other: FileMask) -> bool {
1224 self.0 & other.0 != 0
1225 }
1226}
1227
1228impl PartialEq for SettingItem {
1229 fn eq(&self, other: &Self) -> bool {
1230 self.title == other.title
1231 && self.description == other.description
1232 && (match (&self.metadata, &other.metadata) {
1233 (None, None) => true,
1234 (Some(m1), Some(m2)) => m1.placeholder == m2.placeholder,
1235 _ => false,
1236 })
1237 }
1238}
1239
1240#[derive(Clone)]
1241struct SubPageLink {
1242 title: SharedString,
1243 description: Option<SharedString>,
1244 /// See [`SettingField.json_path`]
1245 json_path: Option<&'static str>,
1246 /// Whether or not the settings in this sub page are configurable in settings.json
1247 /// Removes the "Edit in settings.json" button from the page.
1248 in_json: bool,
1249 files: FileMask,
1250 render: Arc<
1251 dyn Fn(&mut SettingsWindow, &mut Window, &mut Context<SettingsWindow>) -> AnyElement
1252 + 'static
1253 + Send
1254 + Sync,
1255 >,
1256}
1257
1258impl PartialEq for SubPageLink {
1259 fn eq(&self, other: &Self) -> bool {
1260 self.title == other.title
1261 }
1262}
1263
1264#[derive(Clone)]
1265struct ActionLink {
1266 title: SharedString,
1267 description: Option<SharedString>,
1268 button_text: SharedString,
1269 on_click: Arc<dyn Fn(&mut SettingsWindow, &mut Window, &mut App) + Send + Sync>,
1270}
1271
1272impl PartialEq for ActionLink {
1273 fn eq(&self, other: &Self) -> bool {
1274 self.title == other.title
1275 }
1276}
1277
1278fn all_language_names(cx: &App) -> Vec<SharedString> {
1279 workspace::AppState::global(cx)
1280 .upgrade()
1281 .map_or(vec![], |state| {
1282 state
1283 .languages
1284 .language_names()
1285 .into_iter()
1286 .filter(|name| name.as_ref() != "Zed Keybind Context")
1287 .map(Into::into)
1288 .collect()
1289 })
1290}
1291
1292#[allow(unused)]
1293#[derive(Clone, PartialEq, Debug)]
1294enum SettingsUiFile {
1295 User, // Uses all settings.
1296 Project((WorktreeId, Arc<RelPath>)), // Has a special name, and special set of settings
1297 Server(&'static str), // Uses a special name, and the user settings
1298}
1299
1300impl SettingsUiFile {
1301 fn setting_type(&self) -> &'static str {
1302 match self {
1303 SettingsUiFile::User => "User",
1304 SettingsUiFile::Project(_) => "Project",
1305 SettingsUiFile::Server(_) => "Server",
1306 }
1307 }
1308
1309 fn is_server(&self) -> bool {
1310 matches!(self, SettingsUiFile::Server(_))
1311 }
1312
1313 fn worktree_id(&self) -> Option<WorktreeId> {
1314 match self {
1315 SettingsUiFile::User => None,
1316 SettingsUiFile::Project((worktree_id, _)) => Some(*worktree_id),
1317 SettingsUiFile::Server(_) => None,
1318 }
1319 }
1320
1321 fn from_settings(file: settings::SettingsFile) -> Option<Self> {
1322 Some(match file {
1323 settings::SettingsFile::User => SettingsUiFile::User,
1324 settings::SettingsFile::Project(location) => SettingsUiFile::Project(location),
1325 settings::SettingsFile::Server => SettingsUiFile::Server("todo: server name"),
1326 settings::SettingsFile::Default => return None,
1327 settings::SettingsFile::Global => return None,
1328 })
1329 }
1330
1331 fn to_settings(&self) -> settings::SettingsFile {
1332 match self {
1333 SettingsUiFile::User => settings::SettingsFile::User,
1334 SettingsUiFile::Project(location) => settings::SettingsFile::Project(location.clone()),
1335 SettingsUiFile::Server(_) => settings::SettingsFile::Server,
1336 }
1337 }
1338
1339 fn mask(&self) -> FileMask {
1340 match self {
1341 SettingsUiFile::User => USER,
1342 SettingsUiFile::Project(_) => PROJECT,
1343 SettingsUiFile::Server(_) => SERVER,
1344 }
1345 }
1346}
1347
1348impl SettingsWindow {
1349 fn new(
1350 original_window: Option<WindowHandle<Workspace>>,
1351 window: &mut Window,
1352 cx: &mut Context<Self>,
1353 ) -> Self {
1354 let font_family_cache = theme::FontFamilyCache::global(cx);
1355
1356 cx.spawn(async move |this, cx| {
1357 font_family_cache.prefetch(cx).await;
1358 this.update(cx, |_, cx| {
1359 cx.notify();
1360 })
1361 })
1362 .detach();
1363
1364 let current_file = SettingsUiFile::User;
1365 let search_bar = cx.new(|cx| {
1366 let mut editor = Editor::single_line(window, cx);
1367 editor.set_placeholder_text("Search settings…", window, cx);
1368 editor
1369 });
1370
1371 cx.subscribe(&search_bar, |this, _, event: &EditorEvent, cx| {
1372 let EditorEvent::Edited { transaction_id: _ } = event else {
1373 return;
1374 };
1375
1376 this.update_matches(cx);
1377 })
1378 .detach();
1379
1380 let mut ui_font_size = ThemeSettings::get_global(cx).ui_font_size(cx);
1381 cx.observe_global_in::<SettingsStore>(window, move |this, window, cx| {
1382 this.fetch_files(window, cx);
1383
1384 // Whenever settings are changed, it's possible that the changed
1385 // settings affects the rendering of the `SettingsWindow`, like is
1386 // the case with `ui_font_size`. When that happens, we need to
1387 // instruct the `ListState` to re-measure the list items, as the
1388 // list item heights may have changed depending on the new font
1389 // size.
1390 let new_ui_font_size = ThemeSettings::get_global(cx).ui_font_size(cx);
1391 if new_ui_font_size != ui_font_size {
1392 this.list_state.remeasure();
1393 ui_font_size = new_ui_font_size;
1394 }
1395
1396 cx.notify();
1397 })
1398 .detach();
1399
1400 cx.on_window_closed(|cx| {
1401 if let Some(existing_window) = cx
1402 .windows()
1403 .into_iter()
1404 .find_map(|window| window.downcast::<SettingsWindow>())
1405 && cx.windows().len() == 1
1406 {
1407 cx.update_window(*existing_window, |_, window, _| {
1408 window.remove_window();
1409 })
1410 .ok();
1411
1412 telemetry::event!("Settings Closed")
1413 }
1414 })
1415 .detach();
1416
1417 if let Some(app_state) = AppState::global(cx).upgrade() {
1418 for project in app_state
1419 .workspace_store
1420 .read(cx)
1421 .workspaces()
1422 .iter()
1423 .filter_map(|space| {
1424 space
1425 .read(cx)
1426 .ok()
1427 .map(|workspace| workspace.project().clone())
1428 })
1429 .collect::<Vec<_>>()
1430 {
1431 cx.observe_release_in(&project, window, |this, _, window, cx| {
1432 this.fetch_files(window, cx)
1433 })
1434 .detach();
1435 cx.subscribe_in(&project, window, Self::handle_project_event)
1436 .detach();
1437 }
1438
1439 for workspace in app_state
1440 .workspace_store
1441 .read(cx)
1442 .workspaces()
1443 .iter()
1444 .filter_map(|space| space.entity(cx).ok())
1445 {
1446 cx.observe_release_in(&workspace, window, |this, _, window, cx| {
1447 this.fetch_files(window, cx)
1448 })
1449 .detach();
1450 }
1451 } else {
1452 log::error!("App state doesn't exist when creating a new settings window");
1453 }
1454
1455 let this_weak = cx.weak_entity();
1456 cx.observe_new::<Project>({
1457 let this_weak = this_weak.clone();
1458
1459 move |_, window, cx| {
1460 let project = cx.entity();
1461 let Some(window) = window else {
1462 return;
1463 };
1464
1465 this_weak
1466 .update(cx, |this, cx| {
1467 this.fetch_files(window, cx);
1468 cx.observe_release_in(&project, window, |_, _, window, cx| {
1469 cx.defer_in(window, |this, window, cx| this.fetch_files(window, cx));
1470 })
1471 .detach();
1472
1473 cx.subscribe_in(&project, window, Self::handle_project_event)
1474 .detach();
1475 })
1476 .ok();
1477 }
1478 })
1479 .detach();
1480
1481 cx.observe_new::<Workspace>(move |_, window, cx| {
1482 let workspace = cx.entity();
1483 let Some(window) = window else {
1484 return;
1485 };
1486
1487 this_weak
1488 .update(cx, |this, cx| {
1489 this.fetch_files(window, cx);
1490 cx.observe_release_in(&workspace, window, |this, _, window, cx| {
1491 this.fetch_files(window, cx)
1492 })
1493 .detach();
1494 })
1495 .ok();
1496 })
1497 .detach();
1498
1499 let title_bar = if !cfg!(target_os = "macos") {
1500 Some(cx.new(|cx| PlatformTitleBar::new("settings-title-bar", cx)))
1501 } else {
1502 None
1503 };
1504
1505 let list_state = gpui::ListState::new(0, gpui::ListAlignment::Top, px(0.0)).measure_all();
1506 list_state.set_scroll_handler(|_, _, _| {});
1507
1508 let mut this = Self {
1509 title_bar,
1510 original_window,
1511
1512 worktree_root_dirs: HashMap::default(),
1513 files: vec![],
1514
1515 current_file: current_file,
1516 pages: vec![],
1517 navbar_entries: vec![],
1518 navbar_entry: 0,
1519 navbar_scroll_handle: UniformListScrollHandle::default(),
1520 search_bar,
1521 search_task: None,
1522 filter_table: vec![],
1523 has_query: false,
1524 content_handles: vec![],
1525 sub_page_scroll_handle: ScrollHandle::new(),
1526 focus_handle: cx.focus_handle(),
1527 navbar_focus_handle: NonFocusableHandle::new(
1528 NAVBAR_CONTAINER_TAB_INDEX,
1529 false,
1530 window,
1531 cx,
1532 ),
1533 navbar_focus_subscriptions: vec![],
1534 content_focus_handle: NonFocusableHandle::new(
1535 CONTENT_CONTAINER_TAB_INDEX,
1536 false,
1537 window,
1538 cx,
1539 ),
1540 files_focus_handle: cx
1541 .focus_handle()
1542 .tab_index(HEADER_CONTAINER_TAB_INDEX)
1543 .tab_stop(false),
1544 search_index: None,
1545 shown_errors: HashSet::default(),
1546 list_state,
1547 };
1548
1549 this.fetch_files(window, cx);
1550 this.build_ui(window, cx);
1551 this.build_search_index();
1552
1553 this.search_bar.update(cx, |editor, cx| {
1554 editor.focus_handle(cx).focus(window, cx);
1555 });
1556
1557 this
1558 }
1559
1560 fn handle_project_event(
1561 &mut self,
1562 _: &Entity<Project>,
1563 event: &project::Event,
1564 window: &mut Window,
1565 cx: &mut Context<SettingsWindow>,
1566 ) {
1567 match event {
1568 project::Event::WorktreeRemoved(_) | project::Event::WorktreeAdded(_) => {
1569 cx.defer_in(window, |this, window, cx| {
1570 this.fetch_files(window, cx);
1571 });
1572 }
1573 _ => {}
1574 }
1575 }
1576
1577 fn toggle_navbar_entry(&mut self, nav_entry_index: usize) {
1578 // We can only toggle root entries
1579 if !self.navbar_entries[nav_entry_index].is_root {
1580 return;
1581 }
1582
1583 let expanded = &mut self.navbar_entries[nav_entry_index].expanded;
1584 *expanded = !*expanded;
1585 self.navbar_entry = nav_entry_index;
1586 self.reset_list_state();
1587 }
1588
1589 fn build_navbar(&mut self, cx: &App) {
1590 let mut navbar_entries = Vec::new();
1591
1592 for (page_index, page) in self.pages.iter().enumerate() {
1593 navbar_entries.push(NavBarEntry {
1594 title: page.title,
1595 is_root: true,
1596 expanded: false,
1597 page_index,
1598 item_index: None,
1599 focus_handle: cx.focus_handle().tab_index(0).tab_stop(true),
1600 });
1601
1602 for (item_index, item) in page.items.iter().enumerate() {
1603 let SettingsPageItem::SectionHeader(title) = item else {
1604 continue;
1605 };
1606 navbar_entries.push(NavBarEntry {
1607 title,
1608 is_root: false,
1609 expanded: false,
1610 page_index,
1611 item_index: Some(item_index),
1612 focus_handle: cx.focus_handle().tab_index(0).tab_stop(true),
1613 });
1614 }
1615 }
1616
1617 self.navbar_entries = navbar_entries;
1618 }
1619
1620 fn setup_navbar_focus_subscriptions(
1621 &mut self,
1622 window: &mut Window,
1623 cx: &mut Context<SettingsWindow>,
1624 ) {
1625 let mut focus_subscriptions = Vec::new();
1626
1627 for entry_index in 0..self.navbar_entries.len() {
1628 let focus_handle = self.navbar_entries[entry_index].focus_handle.clone();
1629
1630 let subscription = cx.on_focus(
1631 &focus_handle,
1632 window,
1633 move |this: &mut SettingsWindow,
1634 window: &mut Window,
1635 cx: &mut Context<SettingsWindow>| {
1636 this.open_and_scroll_to_navbar_entry(entry_index, None, false, window, cx);
1637 },
1638 );
1639 focus_subscriptions.push(subscription);
1640 }
1641 self.navbar_focus_subscriptions = focus_subscriptions;
1642 }
1643
1644 fn visible_navbar_entries(&self) -> impl Iterator<Item = (usize, &NavBarEntry)> {
1645 let mut index = 0;
1646 let entries = &self.navbar_entries;
1647 let search_matches = &self.filter_table;
1648 let has_query = self.has_query;
1649 std::iter::from_fn(move || {
1650 while index < entries.len() {
1651 let entry = &entries[index];
1652 let included_in_search = if let Some(item_index) = entry.item_index {
1653 search_matches[entry.page_index][item_index]
1654 } else {
1655 search_matches[entry.page_index].iter().any(|b| *b)
1656 || search_matches[entry.page_index].is_empty()
1657 };
1658 if included_in_search {
1659 break;
1660 }
1661 index += 1;
1662 }
1663 if index >= self.navbar_entries.len() {
1664 return None;
1665 }
1666 let entry = &entries[index];
1667 let entry_index = index;
1668
1669 index += 1;
1670 if entry.is_root && !entry.expanded && !has_query {
1671 while index < entries.len() {
1672 if entries[index].is_root {
1673 break;
1674 }
1675 index += 1;
1676 }
1677 }
1678
1679 return Some((entry_index, entry));
1680 })
1681 }
1682
1683 fn filter_matches_to_file(&mut self) {
1684 let current_file = self.current_file.mask();
1685 for (page, page_filter) in std::iter::zip(&self.pages, &mut self.filter_table) {
1686 let mut header_index = 0;
1687 let mut any_found_since_last_header = true;
1688
1689 for (index, item) in page.items.iter().enumerate() {
1690 match item {
1691 SettingsPageItem::SectionHeader(_) => {
1692 if !any_found_since_last_header {
1693 page_filter[header_index] = false;
1694 }
1695 header_index = index;
1696 any_found_since_last_header = false;
1697 }
1698 SettingsPageItem::SettingItem(SettingItem { files, .. })
1699 | SettingsPageItem::SubPageLink(SubPageLink { files, .. })
1700 | SettingsPageItem::DynamicItem(DynamicItem {
1701 discriminant: SettingItem { files, .. },
1702 ..
1703 }) => {
1704 if !files.contains(current_file) {
1705 page_filter[index] = false;
1706 } else {
1707 any_found_since_last_header = true;
1708 }
1709 }
1710 SettingsPageItem::ActionLink(_) => {
1711 any_found_since_last_header = true;
1712 }
1713 }
1714 }
1715 if let Some(last_header) = page_filter.get_mut(header_index)
1716 && !any_found_since_last_header
1717 {
1718 *last_header = false;
1719 }
1720 }
1721 }
1722
1723 fn update_matches(&mut self, cx: &mut Context<SettingsWindow>) {
1724 self.search_task.take();
1725 let mut query = self.search_bar.read(cx).text(cx);
1726 if query.is_empty() || self.search_index.is_none() {
1727 for page in &mut self.filter_table {
1728 page.fill(true);
1729 }
1730 self.has_query = false;
1731 self.filter_matches_to_file();
1732 self.reset_list_state();
1733 cx.notify();
1734 return;
1735 }
1736
1737 let is_json_link_query;
1738 if query.starts_with("#") {
1739 query.remove(0);
1740 is_json_link_query = true;
1741 } else {
1742 is_json_link_query = false;
1743 }
1744
1745 let search_index = self.search_index.as_ref().unwrap().clone();
1746
1747 fn update_matches_inner(
1748 this: &mut SettingsWindow,
1749 search_index: &SearchIndex,
1750 match_indices: impl Iterator<Item = usize>,
1751 cx: &mut Context<SettingsWindow>,
1752 ) {
1753 for page in &mut this.filter_table {
1754 page.fill(false);
1755 }
1756
1757 for match_index in match_indices {
1758 let SearchKeyLUTEntry {
1759 page_index,
1760 header_index,
1761 item_index,
1762 ..
1763 } = search_index.key_lut[match_index];
1764 let page = &mut this.filter_table[page_index];
1765 page[header_index] = true;
1766 page[item_index] = true;
1767 }
1768 this.has_query = true;
1769 this.filter_matches_to_file();
1770 this.open_first_nav_page();
1771 this.reset_list_state();
1772 cx.notify();
1773 }
1774
1775 self.search_task = Some(cx.spawn(async move |this, cx| {
1776 if is_json_link_query {
1777 let mut indices = vec![];
1778 for (index, SearchKeyLUTEntry { json_path, .. }) in
1779 search_index.key_lut.iter().enumerate()
1780 {
1781 let Some(json_path) = json_path else {
1782 continue;
1783 };
1784
1785 if let Some(post) = query.strip_prefix(json_path)
1786 && (post.is_empty() || post.starts_with('.'))
1787 {
1788 indices.push(index);
1789 }
1790 }
1791 if !indices.is_empty() {
1792 this.update(cx, |this, cx| {
1793 update_matches_inner(this, search_index.as_ref(), indices.into_iter(), cx);
1794 })
1795 .ok();
1796 return;
1797 }
1798 }
1799 let bm25_task = cx.background_spawn({
1800 let search_index = search_index.clone();
1801 let max_results = search_index.key_lut.len();
1802 let query = query.clone();
1803 async move { search_index.bm25_engine.search(&query, max_results) }
1804 });
1805 let cancel_flag = std::sync::atomic::AtomicBool::new(false);
1806 let fuzzy_search_task = fuzzy::match_strings(
1807 search_index.fuzzy_match_candidates.as_slice(),
1808 &query,
1809 false,
1810 true,
1811 search_index.fuzzy_match_candidates.len(),
1812 &cancel_flag,
1813 cx.background_executor().clone(),
1814 );
1815
1816 let fuzzy_matches = fuzzy_search_task.await;
1817
1818 _ = this
1819 .update(cx, |this, cx| {
1820 // For tuning the score threshold
1821 // for fuzzy_match in &fuzzy_matches {
1822 // let SearchItemKey {
1823 // page_index,
1824 // header_index,
1825 // item_index,
1826 // } = search_index.key_lut[fuzzy_match.candidate_id];
1827 // let SettingsPageItem::SectionHeader(header) =
1828 // this.pages[page_index].items[header_index]
1829 // else {
1830 // continue;
1831 // };
1832 // let SettingsPageItem::SettingItem(SettingItem {
1833 // title, description, ..
1834 // }) = this.pages[page_index].items[item_index]
1835 // else {
1836 // continue;
1837 // };
1838 // let score = fuzzy_match.score;
1839 // eprint!("# {header} :: QUERY = {query} :: SCORE = {score}\n{title}\n{description}\n\n");
1840 // }
1841 update_matches_inner(
1842 this,
1843 search_index.as_ref(),
1844 fuzzy_matches
1845 .into_iter()
1846 // MAGIC NUMBER: Was found to have right balance between not too many weird matches, but also
1847 // flexible enough to catch misspellings and <4 letter queries
1848 // More flexible is good for us here because fuzzy matches will only be used for things that don't
1849 // match using bm25
1850 .take_while(|fuzzy_match| fuzzy_match.score >= 0.3)
1851 .map(|fuzzy_match| fuzzy_match.candidate_id),
1852 cx,
1853 );
1854 })
1855 .ok();
1856
1857 let bm25_matches = bm25_task.await;
1858
1859 _ = this
1860 .update(cx, |this, cx| {
1861 if bm25_matches.is_empty() {
1862 return;
1863 }
1864 update_matches_inner(
1865 this,
1866 search_index.as_ref(),
1867 bm25_matches
1868 .into_iter()
1869 .map(|bm25_match| bm25_match.document.id),
1870 cx,
1871 );
1872 })
1873 .ok();
1874
1875 cx.background_executor().timer(Duration::from_secs(1)).await;
1876 telemetry::event!("Settings Searched", query = query)
1877 }));
1878 }
1879
1880 fn build_filter_table(&mut self) {
1881 self.filter_table = self
1882 .pages
1883 .iter()
1884 .map(|page| vec![true; page.items.len()])
1885 .collect::<Vec<_>>();
1886 }
1887
1888 fn build_search_index(&mut self) {
1889 let mut key_lut: Vec<SearchKeyLUTEntry> = vec![];
1890 let mut documents = Vec::default();
1891 let mut fuzzy_match_candidates = Vec::default();
1892
1893 fn push_candidates(
1894 fuzzy_match_candidates: &mut Vec<StringMatchCandidate>,
1895 key_index: usize,
1896 input: &str,
1897 ) {
1898 for word in input.split_ascii_whitespace() {
1899 fuzzy_match_candidates.push(StringMatchCandidate::new(key_index, word));
1900 }
1901 }
1902
1903 // PERF: We are currently searching all items even in project files
1904 // where many settings are filtered out, using the logic in filter_matches_to_file
1905 // we could only search relevant items based on the current file
1906 for (page_index, page) in self.pages.iter().enumerate() {
1907 let mut header_index = 0;
1908 let mut header_str = "";
1909 for (item_index, item) in page.items.iter().enumerate() {
1910 let key_index = key_lut.len();
1911 let mut json_path = None;
1912 match item {
1913 SettingsPageItem::DynamicItem(DynamicItem {
1914 discriminant: item, ..
1915 })
1916 | SettingsPageItem::SettingItem(item) => {
1917 json_path = item
1918 .field
1919 .json_path()
1920 .map(|path| path.trim_end_matches('$'));
1921 documents.push(bm25::Document {
1922 id: key_index,
1923 contents: [page.title, header_str, item.title, item.description]
1924 .join("\n"),
1925 });
1926 push_candidates(&mut fuzzy_match_candidates, key_index, item.title);
1927 push_candidates(&mut fuzzy_match_candidates, key_index, item.description);
1928 }
1929 SettingsPageItem::SectionHeader(header) => {
1930 documents.push(bm25::Document {
1931 id: key_index,
1932 contents: header.to_string(),
1933 });
1934 push_candidates(&mut fuzzy_match_candidates, key_index, header);
1935 header_index = item_index;
1936 header_str = *header;
1937 }
1938 SettingsPageItem::SubPageLink(sub_page_link) => {
1939 json_path = sub_page_link.json_path;
1940 documents.push(bm25::Document {
1941 id: key_index,
1942 contents: [page.title, header_str, sub_page_link.title.as_ref()]
1943 .join("\n"),
1944 });
1945 push_candidates(
1946 &mut fuzzy_match_candidates,
1947 key_index,
1948 sub_page_link.title.as_ref(),
1949 );
1950 }
1951 SettingsPageItem::ActionLink(action_link) => {
1952 documents.push(bm25::Document {
1953 id: key_index,
1954 contents: [page.title, header_str, action_link.title.as_ref()]
1955 .join("\n"),
1956 });
1957 push_candidates(
1958 &mut fuzzy_match_candidates,
1959 key_index,
1960 action_link.title.as_ref(),
1961 );
1962 }
1963 }
1964 push_candidates(&mut fuzzy_match_candidates, key_index, page.title);
1965 push_candidates(&mut fuzzy_match_candidates, key_index, header_str);
1966
1967 key_lut.push(SearchKeyLUTEntry {
1968 page_index,
1969 header_index,
1970 item_index,
1971 json_path,
1972 });
1973 }
1974 }
1975 let engine =
1976 bm25::SearchEngineBuilder::with_documents(bm25::Language::English, documents).build();
1977 self.search_index = Some(Arc::new(SearchIndex {
1978 bm25_engine: engine,
1979 key_lut,
1980 fuzzy_match_candidates,
1981 }));
1982 }
1983
1984 fn build_content_handles(&mut self, window: &mut Window, cx: &mut Context<SettingsWindow>) {
1985 self.content_handles = self
1986 .pages
1987 .iter()
1988 .map(|page| {
1989 std::iter::repeat_with(|| NonFocusableHandle::new(0, false, window, cx))
1990 .take(page.items.len())
1991 .collect()
1992 })
1993 .collect::<Vec<_>>();
1994 }
1995
1996 fn reset_list_state(&mut self) {
1997 let mut visible_items_count = self.visible_page_items().count();
1998
1999 if visible_items_count > 0 {
2000 // show page title if page is non empty
2001 visible_items_count += 1;
2002 }
2003
2004 self.list_state.reset(visible_items_count);
2005 }
2006
2007 fn build_ui(&mut self, window: &mut Window, cx: &mut Context<SettingsWindow>) {
2008 if self.pages.is_empty() {
2009 self.pages = page_data::settings_data(cx);
2010 self.build_navbar(cx);
2011 self.setup_navbar_focus_subscriptions(window, cx);
2012 self.build_content_handles(window, cx);
2013 }
2014 sub_page_stack_mut().clear();
2015 // PERF: doesn't have to be rebuilt, can just be filled with true. pages is constant once it is built
2016 self.build_filter_table();
2017 self.reset_list_state();
2018 self.update_matches(cx);
2019
2020 cx.notify();
2021 }
2022
2023 #[track_caller]
2024 fn fetch_files(&mut self, window: &mut Window, cx: &mut Context<SettingsWindow>) {
2025 self.worktree_root_dirs.clear();
2026 let prev_files = self.files.clone();
2027 let settings_store = cx.global::<SettingsStore>();
2028 let mut ui_files = vec![];
2029 let mut all_files = settings_store.get_all_files();
2030 if !all_files.contains(&settings::SettingsFile::User) {
2031 all_files.push(settings::SettingsFile::User);
2032 }
2033 for file in all_files {
2034 let Some(settings_ui_file) = SettingsUiFile::from_settings(file) else {
2035 continue;
2036 };
2037 if settings_ui_file.is_server() {
2038 continue;
2039 }
2040
2041 if let Some(worktree_id) = settings_ui_file.worktree_id() {
2042 let directory_name = all_projects(cx)
2043 .find_map(|project| project.read(cx).worktree_for_id(worktree_id, cx))
2044 .and_then(|worktree| worktree.read(cx).root_dir())
2045 .and_then(|root_dir| {
2046 root_dir
2047 .file_name()
2048 .map(|os_string| os_string.to_string_lossy().to_string())
2049 });
2050
2051 let Some(directory_name) = directory_name else {
2052 log::error!(
2053 "No directory name found for settings file at worktree ID: {}",
2054 worktree_id
2055 );
2056 continue;
2057 };
2058
2059 self.worktree_root_dirs.insert(worktree_id, directory_name);
2060 }
2061
2062 let focus_handle = prev_files
2063 .iter()
2064 .find_map(|(prev_file, handle)| {
2065 (prev_file == &settings_ui_file).then(|| handle.clone())
2066 })
2067 .unwrap_or_else(|| cx.focus_handle().tab_index(0).tab_stop(true));
2068 ui_files.push((settings_ui_file, focus_handle));
2069 }
2070
2071 ui_files.reverse();
2072
2073 let mut missing_worktrees = Vec::new();
2074
2075 for worktree in all_projects(cx)
2076 .flat_map(|project| project.read(cx).visible_worktrees(cx))
2077 .filter(|tree| !self.worktree_root_dirs.contains_key(&tree.read(cx).id()))
2078 {
2079 let worktree = worktree.read(cx);
2080 let worktree_id = worktree.id();
2081 let Some(directory_name) = worktree.root_dir().and_then(|file| {
2082 file.file_name()
2083 .map(|os_string| os_string.to_string_lossy().to_string())
2084 }) else {
2085 continue;
2086 };
2087
2088 missing_worktrees.push((worktree_id, directory_name.clone()));
2089 let path = RelPath::empty().to_owned().into_arc();
2090
2091 let settings_ui_file = SettingsUiFile::Project((worktree_id, path));
2092
2093 let focus_handle = prev_files
2094 .iter()
2095 .find_map(|(prev_file, handle)| {
2096 (prev_file == &settings_ui_file).then(|| handle.clone())
2097 })
2098 .unwrap_or_else(|| cx.focus_handle().tab_index(0).tab_stop(true));
2099
2100 ui_files.push((settings_ui_file, focus_handle));
2101 }
2102
2103 self.worktree_root_dirs.extend(missing_worktrees);
2104
2105 self.files = ui_files;
2106 let current_file_still_exists = self
2107 .files
2108 .iter()
2109 .any(|(file, _)| file == &self.current_file);
2110 if !current_file_still_exists {
2111 self.change_file(0, window, cx);
2112 }
2113 }
2114
2115 fn open_navbar_entry_page(&mut self, navbar_entry: usize) {
2116 if !self.is_nav_entry_visible(navbar_entry) {
2117 self.open_first_nav_page();
2118 }
2119
2120 let is_new_page = self.navbar_entries[self.navbar_entry].page_index
2121 != self.navbar_entries[navbar_entry].page_index;
2122 self.navbar_entry = navbar_entry;
2123
2124 // We only need to reset visible items when updating matches
2125 // and selecting a new page
2126 if is_new_page {
2127 self.reset_list_state();
2128 }
2129
2130 sub_page_stack_mut().clear();
2131 }
2132
2133 fn open_first_nav_page(&mut self) {
2134 let Some(first_navbar_entry_index) = self.visible_navbar_entries().next().map(|e| e.0)
2135 else {
2136 return;
2137 };
2138 self.open_navbar_entry_page(first_navbar_entry_index);
2139 }
2140
2141 fn change_file(&mut self, ix: usize, window: &mut Window, cx: &mut Context<SettingsWindow>) {
2142 if ix >= self.files.len() {
2143 self.current_file = SettingsUiFile::User;
2144 self.build_ui(window, cx);
2145 return;
2146 }
2147
2148 if self.files[ix].0 == self.current_file {
2149 return;
2150 }
2151 self.current_file = self.files[ix].0.clone();
2152
2153 if let SettingsUiFile::Project((_, _)) = &self.current_file {
2154 telemetry::event!("Setting Project Clicked");
2155 }
2156
2157 self.build_ui(window, cx);
2158
2159 if self
2160 .visible_navbar_entries()
2161 .any(|(index, _)| index == self.navbar_entry)
2162 {
2163 self.open_and_scroll_to_navbar_entry(self.navbar_entry, None, true, window, cx);
2164 } else {
2165 self.open_first_nav_page();
2166 };
2167 }
2168
2169 fn render_files_header(
2170 &self,
2171 window: &mut Window,
2172 cx: &mut Context<SettingsWindow>,
2173 ) -> impl IntoElement {
2174 static OVERFLOW_LIMIT: usize = 1;
2175
2176 let file_button =
2177 |ix, file: &SettingsUiFile, focus_handle, cx: &mut Context<SettingsWindow>| {
2178 Button::new(
2179 ix,
2180 self.display_name(&file)
2181 .expect("Files should always have a name"),
2182 )
2183 .toggle_state(file == &self.current_file)
2184 .selected_style(ButtonStyle::Tinted(ui::TintColor::Accent))
2185 .track_focus(focus_handle)
2186 .on_click(cx.listener({
2187 let focus_handle = focus_handle.clone();
2188 move |this, _: &gpui::ClickEvent, window, cx| {
2189 this.change_file(ix, window, cx);
2190 focus_handle.focus(window, cx);
2191 }
2192 }))
2193 };
2194
2195 let this = cx.entity();
2196
2197 let selected_file_ix = self
2198 .files
2199 .iter()
2200 .enumerate()
2201 .skip(OVERFLOW_LIMIT)
2202 .find_map(|(ix, (file, _))| {
2203 if file == &self.current_file {
2204 Some(ix)
2205 } else {
2206 None
2207 }
2208 })
2209 .unwrap_or(OVERFLOW_LIMIT);
2210 let edit_in_json_id = SharedString::new(format!("edit-in-json-{}", selected_file_ix));
2211
2212 h_flex()
2213 .w_full()
2214 .gap_1()
2215 .justify_between()
2216 .track_focus(&self.files_focus_handle)
2217 .tab_group()
2218 .tab_index(HEADER_GROUP_TAB_INDEX)
2219 .child(
2220 h_flex()
2221 .gap_1()
2222 .children(
2223 self.files.iter().enumerate().take(OVERFLOW_LIMIT).map(
2224 |(ix, (file, focus_handle))| file_button(ix, file, focus_handle, cx),
2225 ),
2226 )
2227 .when(self.files.len() > OVERFLOW_LIMIT, |div| {
2228 let (file, focus_handle) = &self.files[selected_file_ix];
2229
2230 div.child(file_button(selected_file_ix, file, focus_handle, cx))
2231 .when(self.files.len() > OVERFLOW_LIMIT + 1, |div| {
2232 div.child(
2233 DropdownMenu::new(
2234 "more-files",
2235 format!("+{}", self.files.len() - (OVERFLOW_LIMIT + 1)),
2236 ContextMenu::build(window, cx, move |mut menu, _, _| {
2237 for (mut ix, (file, focus_handle)) in self
2238 .files
2239 .iter()
2240 .enumerate()
2241 .skip(OVERFLOW_LIMIT + 1)
2242 {
2243 let (display_name, focus_handle) =
2244 if selected_file_ix == ix {
2245 ix = OVERFLOW_LIMIT;
2246 (
2247 self.display_name(&self.files[ix].0),
2248 self.files[ix].1.clone(),
2249 )
2250 } else {
2251 (
2252 self.display_name(&file),
2253 focus_handle.clone(),
2254 )
2255 };
2256
2257 menu = menu.entry(
2258 display_name
2259 .expect("Files should always have a name"),
2260 None,
2261 {
2262 let this = this.clone();
2263 move |window, cx| {
2264 this.update(cx, |this, cx| {
2265 this.change_file(ix, window, cx);
2266 });
2267 focus_handle.focus(window, cx);
2268 }
2269 },
2270 );
2271 }
2272
2273 menu
2274 }),
2275 )
2276 .style(DropdownStyle::Subtle)
2277 .trigger_tooltip(Tooltip::text("View Other Projects"))
2278 .trigger_icon(IconName::ChevronDown)
2279 .attach(gpui::Corner::BottomLeft)
2280 .offset(gpui::Point {
2281 x: px(0.0),
2282 y: px(2.0),
2283 })
2284 .tab_index(0),
2285 )
2286 })
2287 }),
2288 )
2289 .child(
2290 Button::new(edit_in_json_id, "Edit in settings.json")
2291 .tab_index(0_isize)
2292 .style(ButtonStyle::OutlinedGhost)
2293 .tooltip(Tooltip::for_action_title_in(
2294 "Edit in settings.json",
2295 &OpenCurrentFile,
2296 &self.focus_handle,
2297 ))
2298 .on_click(cx.listener(|this, _, window, cx| {
2299 this.open_current_settings_file(window, cx);
2300 })),
2301 )
2302 }
2303
2304 pub(crate) fn display_name(&self, file: &SettingsUiFile) -> Option<String> {
2305 match file {
2306 SettingsUiFile::User => Some("User".to_string()),
2307 SettingsUiFile::Project((worktree_id, path)) => self
2308 .worktree_root_dirs
2309 .get(&worktree_id)
2310 .map(|directory_name| {
2311 let path_style = PathStyle::local();
2312 if path.is_empty() {
2313 directory_name.clone()
2314 } else {
2315 format!(
2316 "{}{}{}",
2317 directory_name,
2318 path_style.primary_separator(),
2319 path.display(path_style)
2320 )
2321 }
2322 }),
2323 SettingsUiFile::Server(file) => Some(file.to_string()),
2324 }
2325 }
2326
2327 // TODO:
2328 // Reconsider this after preview launch
2329 // fn file_location_str(&self) -> String {
2330 // match &self.current_file {
2331 // SettingsUiFile::User => "settings.json".to_string(),
2332 // SettingsUiFile::Project((worktree_id, path)) => self
2333 // .worktree_root_dirs
2334 // .get(&worktree_id)
2335 // .map(|directory_name| {
2336 // let path_style = PathStyle::local();
2337 // let file_path = path.join(paths::local_settings_file_relative_path());
2338 // format!(
2339 // "{}{}{}",
2340 // directory_name,
2341 // path_style.separator(),
2342 // file_path.display(path_style)
2343 // )
2344 // })
2345 // .expect("Current file should always be present in root dir map"),
2346 // SettingsUiFile::Server(file) => file.to_string(),
2347 // }
2348 // }
2349
2350 fn render_search(&self, _window: &mut Window, cx: &mut App) -> Div {
2351 h_flex()
2352 .py_1()
2353 .px_1p5()
2354 .mb_3()
2355 .gap_1p5()
2356 .rounded_sm()
2357 .bg(cx.theme().colors().editor_background)
2358 .border_1()
2359 .border_color(cx.theme().colors().border)
2360 .child(Icon::new(IconName::MagnifyingGlass).color(Color::Muted))
2361 .child(self.search_bar.clone())
2362 }
2363
2364 fn render_nav(
2365 &self,
2366 window: &mut Window,
2367 cx: &mut Context<SettingsWindow>,
2368 ) -> impl IntoElement {
2369 let visible_count = self.visible_navbar_entries().count();
2370
2371 let focus_keybind_label = if self
2372 .navbar_focus_handle
2373 .read(cx)
2374 .handle
2375 .contains_focused(window, cx)
2376 || self
2377 .visible_navbar_entries()
2378 .any(|(_, entry)| entry.focus_handle.is_focused(window))
2379 {
2380 "Focus Content"
2381 } else {
2382 "Focus Navbar"
2383 };
2384
2385 let mut key_context = KeyContext::new_with_defaults();
2386 key_context.add("NavigationMenu");
2387 key_context.add("menu");
2388 if self.search_bar.focus_handle(cx).is_focused(window) {
2389 key_context.add("search");
2390 }
2391
2392 v_flex()
2393 .key_context(key_context)
2394 .on_action(cx.listener(|this, _: &CollapseNavEntry, window, cx| {
2395 let Some(focused_entry) = this.focused_nav_entry(window, cx) else {
2396 return;
2397 };
2398 let focused_entry_parent = this.root_entry_containing(focused_entry);
2399 if this.navbar_entries[focused_entry_parent].expanded {
2400 this.toggle_navbar_entry(focused_entry_parent);
2401 window.focus(&this.navbar_entries[focused_entry_parent].focus_handle, cx);
2402 }
2403 cx.notify();
2404 }))
2405 .on_action(cx.listener(|this, _: &ExpandNavEntry, window, cx| {
2406 let Some(focused_entry) = this.focused_nav_entry(window, cx) else {
2407 return;
2408 };
2409 if !this.navbar_entries[focused_entry].is_root {
2410 return;
2411 }
2412 if !this.navbar_entries[focused_entry].expanded {
2413 this.toggle_navbar_entry(focused_entry);
2414 }
2415 cx.notify();
2416 }))
2417 .on_action(
2418 cx.listener(|this, _: &FocusPreviousRootNavEntry, window, cx| {
2419 let entry_index = this
2420 .focused_nav_entry(window, cx)
2421 .unwrap_or(this.navbar_entry);
2422 let mut root_index = None;
2423 for (index, entry) in this.visible_navbar_entries() {
2424 if index >= entry_index {
2425 break;
2426 }
2427 if entry.is_root {
2428 root_index = Some(index);
2429 }
2430 }
2431 let Some(previous_root_index) = root_index else {
2432 return;
2433 };
2434 this.focus_and_scroll_to_nav_entry(previous_root_index, window, cx);
2435 }),
2436 )
2437 .on_action(cx.listener(|this, _: &FocusNextRootNavEntry, window, cx| {
2438 let entry_index = this
2439 .focused_nav_entry(window, cx)
2440 .unwrap_or(this.navbar_entry);
2441 let mut root_index = None;
2442 for (index, entry) in this.visible_navbar_entries() {
2443 if index <= entry_index {
2444 continue;
2445 }
2446 if entry.is_root {
2447 root_index = Some(index);
2448 break;
2449 }
2450 }
2451 let Some(next_root_index) = root_index else {
2452 return;
2453 };
2454 this.focus_and_scroll_to_nav_entry(next_root_index, window, cx);
2455 }))
2456 .on_action(cx.listener(|this, _: &FocusFirstNavEntry, window, cx| {
2457 if let Some((first_entry_index, _)) = this.visible_navbar_entries().next() {
2458 this.focus_and_scroll_to_nav_entry(first_entry_index, window, cx);
2459 }
2460 }))
2461 .on_action(cx.listener(|this, _: &FocusLastNavEntry, window, cx| {
2462 if let Some((last_entry_index, _)) = this.visible_navbar_entries().last() {
2463 this.focus_and_scroll_to_nav_entry(last_entry_index, window, cx);
2464 }
2465 }))
2466 .on_action(cx.listener(|this, _: &FocusNextNavEntry, window, cx| {
2467 let entry_index = this
2468 .focused_nav_entry(window, cx)
2469 .unwrap_or(this.navbar_entry);
2470 let mut next_index = None;
2471 for (index, _) in this.visible_navbar_entries() {
2472 if index > entry_index {
2473 next_index = Some(index);
2474 break;
2475 }
2476 }
2477 let Some(next_entry_index) = next_index else {
2478 return;
2479 };
2480 this.open_and_scroll_to_navbar_entry(
2481 next_entry_index,
2482 Some(gpui::ScrollStrategy::Bottom),
2483 false,
2484 window,
2485 cx,
2486 );
2487 }))
2488 .on_action(cx.listener(|this, _: &FocusPreviousNavEntry, window, cx| {
2489 let entry_index = this
2490 .focused_nav_entry(window, cx)
2491 .unwrap_or(this.navbar_entry);
2492 let mut prev_index = None;
2493 for (index, _) in this.visible_navbar_entries() {
2494 if index >= entry_index {
2495 break;
2496 }
2497 prev_index = Some(index);
2498 }
2499 let Some(prev_entry_index) = prev_index else {
2500 return;
2501 };
2502 this.open_and_scroll_to_navbar_entry(
2503 prev_entry_index,
2504 Some(gpui::ScrollStrategy::Top),
2505 false,
2506 window,
2507 cx,
2508 );
2509 }))
2510 .w_56()
2511 .h_full()
2512 .p_2p5()
2513 .when(cfg!(target_os = "macos"), |this| this.pt_10())
2514 .flex_none()
2515 .border_r_1()
2516 .border_color(cx.theme().colors().border)
2517 .bg(cx.theme().colors().panel_background)
2518 .child(self.render_search(window, cx))
2519 .child(
2520 v_flex()
2521 .flex_1()
2522 .overflow_hidden()
2523 .track_focus(&self.navbar_focus_handle.focus_handle(cx))
2524 .tab_group()
2525 .tab_index(NAVBAR_GROUP_TAB_INDEX)
2526 .child(
2527 uniform_list(
2528 "settings-ui-nav-bar",
2529 visible_count + 1,
2530 cx.processor(move |this, range: Range<usize>, _, cx| {
2531 this.visible_navbar_entries()
2532 .skip(range.start.saturating_sub(1))
2533 .take(range.len())
2534 .map(|(entry_index, entry)| {
2535 TreeViewItem::new(
2536 ("settings-ui-navbar-entry", entry_index),
2537 entry.title,
2538 )
2539 .track_focus(&entry.focus_handle)
2540 .root_item(entry.is_root)
2541 .toggle_state(this.is_navbar_entry_selected(entry_index))
2542 .when(entry.is_root, |item| {
2543 item.expanded(entry.expanded || this.has_query)
2544 .on_toggle(cx.listener(
2545 move |this, _, window, cx| {
2546 this.toggle_navbar_entry(entry_index);
2547 window.focus(
2548 &this.navbar_entries[entry_index]
2549 .focus_handle,
2550 cx,
2551 );
2552 cx.notify();
2553 },
2554 ))
2555 })
2556 .on_click({
2557 let category = this.pages[entry.page_index].title;
2558 let subcategory =
2559 (!entry.is_root).then_some(entry.title);
2560
2561 cx.listener(move |this, _, window, cx| {
2562 telemetry::event!(
2563 "Settings Navigation Clicked",
2564 category = category,
2565 subcategory = subcategory
2566 );
2567
2568 this.open_and_scroll_to_navbar_entry(
2569 entry_index,
2570 None,
2571 true,
2572 window,
2573 cx,
2574 );
2575 })
2576 })
2577 })
2578 .collect()
2579 }),
2580 )
2581 .size_full()
2582 .track_scroll(&self.navbar_scroll_handle),
2583 )
2584 .vertical_scrollbar_for(&self.navbar_scroll_handle, window, cx),
2585 )
2586 .child(
2587 h_flex()
2588 .w_full()
2589 .h_8()
2590 .p_2()
2591 .pb_0p5()
2592 .flex_shrink_0()
2593 .border_t_1()
2594 .border_color(cx.theme().colors().border_variant)
2595 .child(
2596 KeybindingHint::new(
2597 KeyBinding::for_action_in(
2598 &ToggleFocusNav,
2599 &self.navbar_focus_handle.focus_handle(cx),
2600 cx,
2601 ),
2602 cx.theme().colors().surface_background.opacity(0.5),
2603 )
2604 .suffix(focus_keybind_label),
2605 ),
2606 )
2607 }
2608
2609 fn open_and_scroll_to_navbar_entry(
2610 &mut self,
2611 navbar_entry_index: usize,
2612 scroll_strategy: Option<gpui::ScrollStrategy>,
2613 focus_content: bool,
2614 window: &mut Window,
2615 cx: &mut Context<Self>,
2616 ) {
2617 self.open_navbar_entry_page(navbar_entry_index);
2618 cx.notify();
2619
2620 let mut handle_to_focus = None;
2621
2622 if self.navbar_entries[navbar_entry_index].is_root
2623 || !self.is_nav_entry_visible(navbar_entry_index)
2624 {
2625 self.sub_page_scroll_handle
2626 .set_offset(point(px(0.), px(0.)));
2627 if focus_content {
2628 let Some(first_item_index) =
2629 self.visible_page_items().next().map(|(index, _)| index)
2630 else {
2631 return;
2632 };
2633 handle_to_focus = Some(self.focus_handle_for_content_element(first_item_index, cx));
2634 } else if !self.is_nav_entry_visible(navbar_entry_index) {
2635 let Some(first_visible_nav_entry_index) =
2636 self.visible_navbar_entries().next().map(|(index, _)| index)
2637 else {
2638 return;
2639 };
2640 self.focus_and_scroll_to_nav_entry(first_visible_nav_entry_index, window, cx);
2641 } else {
2642 handle_to_focus =
2643 Some(self.navbar_entries[navbar_entry_index].focus_handle.clone());
2644 }
2645 } else {
2646 let entry_item_index = self.navbar_entries[navbar_entry_index]
2647 .item_index
2648 .expect("Non-root items should have an item index");
2649 self.scroll_to_content_item(entry_item_index, window, cx);
2650 if focus_content {
2651 handle_to_focus = Some(self.focus_handle_for_content_element(entry_item_index, cx));
2652 } else {
2653 handle_to_focus =
2654 Some(self.navbar_entries[navbar_entry_index].focus_handle.clone());
2655 }
2656 }
2657
2658 if let Some(scroll_strategy) = scroll_strategy
2659 && let Some(logical_entry_index) = self
2660 .visible_navbar_entries()
2661 .into_iter()
2662 .position(|(index, _)| index == navbar_entry_index)
2663 {
2664 self.navbar_scroll_handle
2665 .scroll_to_item(logical_entry_index + 1, scroll_strategy);
2666 }
2667
2668 // Page scroll handle updates the active item index
2669 // in it's next paint call after using scroll_handle.scroll_to_top_of_item
2670 // The call after that updates the offset of the scroll handle. So to
2671 // ensure the scroll handle doesn't lag behind we need to render three frames
2672 // back to back.
2673 cx.on_next_frame(window, move |_, window, cx| {
2674 if let Some(handle) = handle_to_focus.as_ref() {
2675 window.focus(handle, cx);
2676 }
2677
2678 cx.on_next_frame(window, |_, _, cx| {
2679 cx.notify();
2680 });
2681 cx.notify();
2682 });
2683 cx.notify();
2684 }
2685
2686 fn scroll_to_content_item(
2687 &self,
2688 content_item_index: usize,
2689 _window: &mut Window,
2690 cx: &mut Context<Self>,
2691 ) {
2692 let index = self
2693 .visible_page_items()
2694 .position(|(index, _)| index == content_item_index)
2695 .unwrap_or(0);
2696 if index == 0 {
2697 self.sub_page_scroll_handle
2698 .set_offset(point(px(0.), px(0.)));
2699 self.list_state.scroll_to(gpui::ListOffset {
2700 item_ix: 0,
2701 offset_in_item: px(0.),
2702 });
2703 return;
2704 }
2705 self.list_state.scroll_to(gpui::ListOffset {
2706 item_ix: index + 1,
2707 offset_in_item: px(0.),
2708 });
2709 cx.notify();
2710 }
2711
2712 fn is_nav_entry_visible(&self, nav_entry_index: usize) -> bool {
2713 self.visible_navbar_entries()
2714 .any(|(index, _)| index == nav_entry_index)
2715 }
2716
2717 fn focus_and_scroll_to_first_visible_nav_entry(
2718 &self,
2719 window: &mut Window,
2720 cx: &mut Context<Self>,
2721 ) {
2722 if let Some(nav_entry_index) = self.visible_navbar_entries().next().map(|(index, _)| index)
2723 {
2724 self.focus_and_scroll_to_nav_entry(nav_entry_index, window, cx);
2725 }
2726 }
2727
2728 fn focus_and_scroll_to_nav_entry(
2729 &self,
2730 nav_entry_index: usize,
2731 window: &mut Window,
2732 cx: &mut Context<Self>,
2733 ) {
2734 let Some(position) = self
2735 .visible_navbar_entries()
2736 .position(|(index, _)| index == nav_entry_index)
2737 else {
2738 return;
2739 };
2740 self.navbar_scroll_handle
2741 .scroll_to_item(position, gpui::ScrollStrategy::Top);
2742 window.focus(&self.navbar_entries[nav_entry_index].focus_handle, cx);
2743 cx.notify();
2744 }
2745
2746 fn visible_page_items(&self) -> impl Iterator<Item = (usize, &SettingsPageItem)> {
2747 let page_idx = self.current_page_index();
2748
2749 self.current_page()
2750 .items
2751 .iter()
2752 .enumerate()
2753 .filter_map(move |(item_index, item)| {
2754 self.filter_table[page_idx][item_index].then_some((item_index, item))
2755 })
2756 }
2757
2758 fn render_sub_page_breadcrumbs(&self) -> impl IntoElement {
2759 let mut items = vec![];
2760 items.push(self.current_page().title.into());
2761 items.extend(
2762 sub_page_stack()
2763 .iter()
2764 .flat_map(|page| [page.section_header.into(), page.link.title.clone()]),
2765 );
2766
2767 let last = items.pop().unwrap();
2768 h_flex()
2769 .gap_1()
2770 .children(
2771 items
2772 .into_iter()
2773 .flat_map(|item| [item, "/".into()])
2774 .map(|item| Label::new(item).color(Color::Muted)),
2775 )
2776 .child(Label::new(last))
2777 }
2778
2779 fn render_empty_state(&self, search_query: SharedString) -> impl IntoElement {
2780 v_flex()
2781 .size_full()
2782 .items_center()
2783 .justify_center()
2784 .gap_1()
2785 .child(Label::new("No Results"))
2786 .child(
2787 Label::new(search_query)
2788 .size(LabelSize::Small)
2789 .color(Color::Muted),
2790 )
2791 }
2792
2793 fn render_page_items(
2794 &mut self,
2795 page_index: usize,
2796 _window: &mut Window,
2797 cx: &mut Context<SettingsWindow>,
2798 ) -> impl IntoElement {
2799 let mut page_content = v_flex().id("settings-ui-page").size_full();
2800
2801 let has_active_search = !self.search_bar.read(cx).is_empty(cx);
2802 let has_no_results = self.visible_page_items().next().is_none() && has_active_search;
2803
2804 if has_no_results {
2805 let search_query = self.search_bar.read(cx).text(cx);
2806 page_content = page_content.child(
2807 self.render_empty_state(format!("No settings match \"{}\"", search_query).into()),
2808 )
2809 } else {
2810 let last_non_header_index = self
2811 .visible_page_items()
2812 .filter_map(|(index, item)| {
2813 (!matches!(item, SettingsPageItem::SectionHeader(_))).then_some(index)
2814 })
2815 .last();
2816
2817 let root_nav_label = self
2818 .navbar_entries
2819 .iter()
2820 .find(|entry| entry.is_root && entry.page_index == self.current_page_index())
2821 .map(|entry| entry.title);
2822
2823 let list_content = list(
2824 self.list_state.clone(),
2825 cx.processor(move |this, index, window, cx| {
2826 if index == 0 {
2827 return div()
2828 .px_8()
2829 .when(sub_page_stack().is_empty(), |this| {
2830 this.when_some(root_nav_label, |this, title| {
2831 this.child(
2832 Label::new(title).size(LabelSize::Large).mt_2().mb_3(),
2833 )
2834 })
2835 })
2836 .into_any_element();
2837 }
2838
2839 let mut visible_items = this.visible_page_items();
2840 let Some((actual_item_index, item)) = visible_items.nth(index - 1) else {
2841 return gpui::Empty.into_any_element();
2842 };
2843
2844 let no_bottom_border = visible_items
2845 .next()
2846 .map(|(_, item)| matches!(item, SettingsPageItem::SectionHeader(_)))
2847 .unwrap_or(false);
2848
2849 let is_last = Some(actual_item_index) == last_non_header_index;
2850
2851 let item_focus_handle =
2852 this.content_handles[page_index][actual_item_index].focus_handle(cx);
2853
2854 v_flex()
2855 .id(("settings-page-item", actual_item_index))
2856 .track_focus(&item_focus_handle)
2857 .w_full()
2858 .min_w_0()
2859 .child(item.render(
2860 this,
2861 actual_item_index,
2862 no_bottom_border || is_last,
2863 window,
2864 cx,
2865 ))
2866 .into_any_element()
2867 }),
2868 );
2869
2870 page_content = page_content.child(list_content.size_full())
2871 }
2872 page_content
2873 }
2874
2875 fn render_sub_page_items<'a, Items>(
2876 &self,
2877 items: Items,
2878 page_index: Option<usize>,
2879 window: &mut Window,
2880 cx: &mut Context<SettingsWindow>,
2881 ) -> impl IntoElement
2882 where
2883 Items: Iterator<Item = (usize, &'a SettingsPageItem)>,
2884 {
2885 let page_content = v_flex()
2886 .id("settings-ui-page")
2887 .size_full()
2888 .overflow_y_scroll()
2889 .track_scroll(&self.sub_page_scroll_handle);
2890 self.render_sub_page_items_in(page_content, items, page_index, window, cx)
2891 }
2892
2893 fn render_sub_page_items_section<'a, Items>(
2894 &self,
2895 items: Items,
2896 page_index: Option<usize>,
2897 window: &mut Window,
2898 cx: &mut Context<SettingsWindow>,
2899 ) -> impl IntoElement
2900 where
2901 Items: Iterator<Item = (usize, &'a SettingsPageItem)>,
2902 {
2903 let page_content = v_flex().id("settings-ui-sub-page-section").size_full();
2904 self.render_sub_page_items_in(page_content, items, page_index, window, cx)
2905 }
2906
2907 fn render_sub_page_items_in<'a, Items>(
2908 &self,
2909 mut page_content: Stateful<Div>,
2910 items: Items,
2911 page_index: Option<usize>,
2912 window: &mut Window,
2913 cx: &mut Context<SettingsWindow>,
2914 ) -> impl IntoElement
2915 where
2916 Items: Iterator<Item = (usize, &'a SettingsPageItem)>,
2917 {
2918 let items: Vec<_> = items.collect();
2919 let items_len = items.len();
2920 let mut section_header = None;
2921
2922 let has_active_search = !self.search_bar.read(cx).is_empty(cx);
2923 let has_no_results = items_len == 0 && has_active_search;
2924
2925 if has_no_results {
2926 let search_query = self.search_bar.read(cx).text(cx);
2927 page_content = page_content.child(
2928 self.render_empty_state(format!("No settings match \"{}\"", search_query).into()),
2929 )
2930 } else {
2931 let last_non_header_index = items
2932 .iter()
2933 .enumerate()
2934 .rev()
2935 .find(|(_, (_, item))| !matches!(item, SettingsPageItem::SectionHeader(_)))
2936 .map(|(index, _)| index);
2937
2938 let root_nav_label = self
2939 .navbar_entries
2940 .iter()
2941 .find(|entry| entry.is_root && entry.page_index == self.current_page_index())
2942 .map(|entry| entry.title);
2943
2944 page_content = page_content
2945 .when(sub_page_stack().is_empty(), |this| {
2946 this.when_some(root_nav_label, |this, title| {
2947 this.child(Label::new(title).size(LabelSize::Large).mt_2().mb_3())
2948 })
2949 })
2950 .children(items.clone().into_iter().enumerate().map(
2951 |(index, (actual_item_index, item))| {
2952 let no_bottom_border = items
2953 .get(index + 1)
2954 .map(|(_, next_item)| {
2955 matches!(next_item, SettingsPageItem::SectionHeader(_))
2956 })
2957 .unwrap_or(false);
2958 let is_last = Some(index) == last_non_header_index;
2959
2960 if let SettingsPageItem::SectionHeader(header) = item {
2961 section_header = Some(*header);
2962 }
2963 v_flex()
2964 .w_full()
2965 .min_w_0()
2966 .id(("settings-page-item", actual_item_index))
2967 .when_some(page_index, |element, page_index| {
2968 element.track_focus(
2969 &self.content_handles[page_index][actual_item_index]
2970 .focus_handle(cx),
2971 )
2972 })
2973 .child(item.render(
2974 self,
2975 actual_item_index,
2976 no_bottom_border || is_last,
2977 window,
2978 cx,
2979 ))
2980 },
2981 ))
2982 }
2983 page_content
2984 }
2985
2986 fn render_page(
2987 &mut self,
2988 window: &mut Window,
2989 cx: &mut Context<SettingsWindow>,
2990 ) -> impl IntoElement {
2991 let page_header;
2992 let page_content;
2993
2994 if sub_page_stack().is_empty() {
2995 page_header = self.render_files_header(window, cx).into_any_element();
2996
2997 page_content = self
2998 .render_page_items(self.current_page_index(), window, cx)
2999 .into_any_element();
3000 } else {
3001 page_header = h_flex()
3002 .w_full()
3003 .justify_between()
3004 .child(
3005 h_flex()
3006 .ml_neg_1p5()
3007 .gap_1()
3008 .child(
3009 IconButton::new("back-btn", IconName::ArrowLeft)
3010 .icon_size(IconSize::Small)
3011 .shape(IconButtonShape::Square)
3012 .on_click(cx.listener(|this, _, window, cx| {
3013 this.pop_sub_page(window, cx);
3014 })),
3015 )
3016 .child(self.render_sub_page_breadcrumbs()),
3017 )
3018 .when(
3019 sub_page_stack()
3020 .last()
3021 .is_none_or(|sub_page| sub_page.link.in_json),
3022 |this| {
3023 this.child(
3024 Button::new("open-in-settings-file", "Edit in settings.json")
3025 .tab_index(0_isize)
3026 .style(ButtonStyle::OutlinedGhost)
3027 .tooltip(Tooltip::for_action_title_in(
3028 "Edit in settings.json",
3029 &OpenCurrentFile,
3030 &self.focus_handle,
3031 ))
3032 .on_click(cx.listener(|this, _, window, cx| {
3033 this.open_current_settings_file(window, cx);
3034 })),
3035 )
3036 },
3037 )
3038 .into_any_element();
3039
3040 let active_page_render_fn = sub_page_stack().last().unwrap().link.render.clone();
3041 page_content = (active_page_render_fn)(self, window, cx);
3042 }
3043
3044 let mut warning_banner = gpui::Empty.into_any_element();
3045 if let Some(error) =
3046 SettingsStore::global(cx).error_for_file(self.current_file.to_settings())
3047 {
3048 fn banner(
3049 label: &'static str,
3050 error: String,
3051 shown_errors: &mut HashSet<String>,
3052 cx: &mut Context<SettingsWindow>,
3053 ) -> impl IntoElement {
3054 if shown_errors.insert(error.clone()) {
3055 telemetry::event!("Settings Error Shown", label = label, error = &error);
3056 }
3057 Banner::new()
3058 .severity(Severity::Warning)
3059 .child(
3060 v_flex()
3061 .my_0p5()
3062 .gap_0p5()
3063 .child(Label::new(label))
3064 .child(Label::new(error).size(LabelSize::Small).color(Color::Muted)),
3065 )
3066 .action_slot(
3067 div().pr_1().pb_1().child(
3068 Button::new("fix-in-json", "Fix in settings.json")
3069 .tab_index(0_isize)
3070 .style(ButtonStyle::Tinted(ui::TintColor::Warning))
3071 .on_click(cx.listener(|this, _, window, cx| {
3072 this.open_current_settings_file(window, cx);
3073 })),
3074 ),
3075 )
3076 }
3077
3078 let parse_error = error.parse_error();
3079 let parse_failed = parse_error.is_some();
3080
3081 warning_banner = v_flex()
3082 .gap_2()
3083 .when_some(parse_error, |this, err| {
3084 this.child(banner(
3085 "Failed to load your settings. Some values may be incorrect and changes may be lost.",
3086 err,
3087 &mut self.shown_errors,
3088 cx,
3089 ))
3090 })
3091 .map(|this| match &error.migration_status {
3092 settings::MigrationStatus::Succeeded => this.child(banner(
3093 "Your settings are out of date, and need to be updated.",
3094 match &self.current_file {
3095 SettingsUiFile::User => "They can be automatically migrated to the latest version.",
3096 SettingsUiFile::Server(_) | SettingsUiFile::Project(_) => "They must be manually migrated to the latest version."
3097 }.to_string(),
3098 &mut self.shown_errors,
3099 cx,
3100 )),
3101 settings::MigrationStatus::Failed { error: err } if !parse_failed => this
3102 .child(banner(
3103 "Your settings file is out of date, automatic migration failed",
3104 err.clone(),
3105 &mut self.shown_errors,
3106 cx,
3107 )),
3108 _ => this,
3109 })
3110 .into_any_element()
3111 }
3112
3113 return v_flex()
3114 .id("settings-ui-page")
3115 .on_action(cx.listener(|this, _: &menu::SelectNext, window, cx| {
3116 if !sub_page_stack().is_empty() {
3117 window.focus_next(cx);
3118 return;
3119 }
3120 for (logical_index, (actual_index, _)) in this.visible_page_items().enumerate() {
3121 let handle = this.content_handles[this.current_page_index()][actual_index]
3122 .focus_handle(cx);
3123 let mut offset = 1; // for page header
3124
3125 if let Some((_, next_item)) = this.visible_page_items().nth(logical_index + 1)
3126 && matches!(next_item, SettingsPageItem::SectionHeader(_))
3127 {
3128 offset += 1;
3129 }
3130 if handle.contains_focused(window, cx) {
3131 let next_logical_index = logical_index + offset + 1;
3132 this.list_state.scroll_to_reveal_item(next_logical_index);
3133 // We need to render the next item to ensure it's focus handle is in the element tree
3134 cx.on_next_frame(window, |_, window, cx| {
3135 cx.notify();
3136 cx.on_next_frame(window, |_, window, cx| {
3137 window.focus_next(cx);
3138 cx.notify();
3139 });
3140 });
3141 cx.notify();
3142 return;
3143 }
3144 }
3145 window.focus_next(cx);
3146 }))
3147 .on_action(cx.listener(|this, _: &menu::SelectPrevious, window, cx| {
3148 if !sub_page_stack().is_empty() {
3149 window.focus_prev(cx);
3150 return;
3151 }
3152 let mut prev_was_header = false;
3153 for (logical_index, (actual_index, item)) in this.visible_page_items().enumerate() {
3154 let is_header = matches!(item, SettingsPageItem::SectionHeader(_));
3155 let handle = this.content_handles[this.current_page_index()][actual_index]
3156 .focus_handle(cx);
3157 let mut offset = 1; // for page header
3158
3159 if prev_was_header {
3160 offset -= 1;
3161 }
3162 if handle.contains_focused(window, cx) {
3163 let next_logical_index = logical_index + offset - 1;
3164 this.list_state.scroll_to_reveal_item(next_logical_index);
3165 // We need to render the next item to ensure it's focus handle is in the element tree
3166 cx.on_next_frame(window, |_, window, cx| {
3167 cx.notify();
3168 cx.on_next_frame(window, |_, window, cx| {
3169 window.focus_prev(cx);
3170 cx.notify();
3171 });
3172 });
3173 cx.notify();
3174 return;
3175 }
3176 prev_was_header = is_header;
3177 }
3178 window.focus_prev(cx);
3179 }))
3180 .when(sub_page_stack().is_empty(), |this| {
3181 this.vertical_scrollbar_for(&self.list_state, window, cx)
3182 })
3183 .when(!sub_page_stack().is_empty(), |this| {
3184 this.vertical_scrollbar_for(&self.sub_page_scroll_handle, window, cx)
3185 })
3186 .track_focus(&self.content_focus_handle.focus_handle(cx))
3187 .pt_6()
3188 .gap_4()
3189 .flex_1()
3190 .bg(cx.theme().colors().editor_background)
3191 .child(
3192 v_flex()
3193 .px_8()
3194 .gap_2()
3195 .child(page_header)
3196 .child(warning_banner),
3197 )
3198 .child(
3199 div()
3200 .flex_1()
3201 .size_full()
3202 .tab_group()
3203 .tab_index(CONTENT_GROUP_TAB_INDEX)
3204 .child(page_content),
3205 );
3206 }
3207
3208 /// This function will create a new settings file if one doesn't exist
3209 /// if the current file is a project settings with a valid worktree id
3210 /// We do this because the settings ui allows initializing project settings
3211 fn open_current_settings_file(&mut self, window: &mut Window, cx: &mut Context<Self>) {
3212 match &self.current_file {
3213 SettingsUiFile::User => {
3214 let Some(original_window) = self.original_window else {
3215 return;
3216 };
3217 original_window
3218 .update(cx, |workspace, window, cx| {
3219 workspace
3220 .with_local_workspace(window, cx, |workspace, window, cx| {
3221 let create_task = workspace.project().update(cx, |project, cx| {
3222 project.find_or_create_worktree(
3223 paths::config_dir().as_path(),
3224 false,
3225 cx,
3226 )
3227 });
3228 let open_task = workspace.open_paths(
3229 vec![paths::settings_file().to_path_buf()],
3230 OpenOptions {
3231 visible: Some(OpenVisible::None),
3232 ..Default::default()
3233 },
3234 None,
3235 window,
3236 cx,
3237 );
3238
3239 cx.spawn_in(window, async move |workspace, cx| {
3240 create_task.await.ok();
3241 open_task.await;
3242
3243 workspace.update_in(cx, |_, window, cx| {
3244 window.activate_window();
3245 cx.notify();
3246 })
3247 })
3248 .detach();
3249 })
3250 .detach();
3251 })
3252 .ok();
3253
3254 window.remove_window();
3255 }
3256 SettingsUiFile::Project((worktree_id, path)) => {
3257 let settings_path = path.join(paths::local_settings_file_relative_path());
3258 let Some(app_state) = workspace::AppState::global(cx).upgrade() else {
3259 return;
3260 };
3261
3262 let Some((worktree, corresponding_workspace)) = app_state
3263 .workspace_store
3264 .read(cx)
3265 .workspaces()
3266 .iter()
3267 .find_map(|workspace| {
3268 workspace
3269 .read_with(cx, |workspace, cx| {
3270 workspace
3271 .project()
3272 .read(cx)
3273 .worktree_for_id(*worktree_id, cx)
3274 })
3275 .ok()
3276 .flatten()
3277 .zip(Some(*workspace))
3278 })
3279 else {
3280 log::error!(
3281 "No corresponding workspace contains worktree id: {}",
3282 worktree_id
3283 );
3284
3285 return;
3286 };
3287
3288 let create_task = if worktree.read(cx).entry_for_path(&settings_path).is_some() {
3289 None
3290 } else {
3291 Some(worktree.update(cx, |tree, cx| {
3292 tree.create_entry(
3293 settings_path.clone(),
3294 false,
3295 Some(initial_project_settings_content().as_bytes().to_vec()),
3296 cx,
3297 )
3298 }))
3299 };
3300
3301 let worktree_id = *worktree_id;
3302
3303 // TODO: move zed::open_local_file() APIs to this crate, and
3304 // re-implement the "initial_contents" behavior
3305 corresponding_workspace
3306 .update(cx, |_, window, cx| {
3307 cx.spawn_in(window, async move |workspace, cx| {
3308 if let Some(create_task) = create_task {
3309 create_task.await.ok()?;
3310 };
3311
3312 workspace
3313 .update_in(cx, |workspace, window, cx| {
3314 workspace.open_path(
3315 (worktree_id, settings_path.clone()),
3316 None,
3317 true,
3318 window,
3319 cx,
3320 )
3321 })
3322 .ok()?
3323 .await
3324 .log_err()?;
3325
3326 workspace
3327 .update_in(cx, |_, window, cx| {
3328 window.activate_window();
3329 cx.notify();
3330 })
3331 .ok();
3332
3333 Some(())
3334 })
3335 .detach();
3336 })
3337 .ok();
3338
3339 window.remove_window();
3340 }
3341 SettingsUiFile::Server(_) => {
3342 // Server files are not editable
3343 return;
3344 }
3345 };
3346 }
3347
3348 fn current_page_index(&self) -> usize {
3349 self.page_index_from_navbar_index(self.navbar_entry)
3350 }
3351
3352 fn current_page(&self) -> &SettingsPage {
3353 &self.pages[self.current_page_index()]
3354 }
3355
3356 fn page_index_from_navbar_index(&self, index: usize) -> usize {
3357 if self.navbar_entries.is_empty() {
3358 return 0;
3359 }
3360
3361 self.navbar_entries[index].page_index
3362 }
3363
3364 fn is_navbar_entry_selected(&self, ix: usize) -> bool {
3365 ix == self.navbar_entry
3366 }
3367
3368 fn push_sub_page(
3369 &mut self,
3370 sub_page_link: SubPageLink,
3371 section_header: &'static str,
3372 window: &mut Window,
3373 cx: &mut Context<SettingsWindow>,
3374 ) {
3375 sub_page_stack_mut().push(SubPage {
3376 link: sub_page_link,
3377 section_header,
3378 });
3379 self.sub_page_scroll_handle
3380 .set_offset(point(px(0.), px(0.)));
3381 self.content_focus_handle.focus_handle(cx).focus(window, cx);
3382 cx.notify();
3383 }
3384
3385 fn pop_sub_page(&mut self, window: &mut Window, cx: &mut Context<SettingsWindow>) {
3386 sub_page_stack_mut().pop();
3387 self.content_focus_handle.focus_handle(cx).focus(window, cx);
3388 cx.notify();
3389 }
3390
3391 fn focus_file_at_index(&mut self, index: usize, window: &mut Window, cx: &mut App) {
3392 if let Some((_, handle)) = self.files.get(index) {
3393 handle.focus(window, cx);
3394 }
3395 }
3396
3397 fn focused_file_index(&self, window: &Window, cx: &Context<Self>) -> usize {
3398 if self.files_focus_handle.contains_focused(window, cx)
3399 && let Some(index) = self
3400 .files
3401 .iter()
3402 .position(|(_, handle)| handle.is_focused(window))
3403 {
3404 return index;
3405 }
3406 if let Some(current_file_index) = self
3407 .files
3408 .iter()
3409 .position(|(file, _)| file == &self.current_file)
3410 {
3411 return current_file_index;
3412 }
3413 0
3414 }
3415
3416 fn focus_handle_for_content_element(
3417 &self,
3418 actual_item_index: usize,
3419 cx: &Context<Self>,
3420 ) -> FocusHandle {
3421 let page_index = self.current_page_index();
3422 self.content_handles[page_index][actual_item_index].focus_handle(cx)
3423 }
3424
3425 fn focused_nav_entry(&self, window: &Window, cx: &App) -> Option<usize> {
3426 if !self
3427 .navbar_focus_handle
3428 .focus_handle(cx)
3429 .contains_focused(window, cx)
3430 {
3431 return None;
3432 }
3433 for (index, entry) in self.navbar_entries.iter().enumerate() {
3434 if entry.focus_handle.is_focused(window) {
3435 return Some(index);
3436 }
3437 }
3438 None
3439 }
3440
3441 fn root_entry_containing(&self, nav_entry_index: usize) -> usize {
3442 let mut index = Some(nav_entry_index);
3443 while let Some(prev_index) = index
3444 && !self.navbar_entries[prev_index].is_root
3445 {
3446 index = prev_index.checked_sub(1);
3447 }
3448 return index.expect("No root entry found");
3449 }
3450}
3451
3452impl Render for SettingsWindow {
3453 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
3454 let ui_font = theme::setup_ui_font(window, cx);
3455
3456 client_side_decorations(
3457 v_flex()
3458 .text_color(cx.theme().colors().text)
3459 .size_full()
3460 .children(self.title_bar.clone())
3461 .child(
3462 div()
3463 .id("settings-window")
3464 .key_context("SettingsWindow")
3465 .track_focus(&self.focus_handle)
3466 .on_action(cx.listener(|this, _: &OpenCurrentFile, window, cx| {
3467 this.open_current_settings_file(window, cx);
3468 }))
3469 .on_action(|_: &Minimize, window, _cx| {
3470 window.minimize_window();
3471 })
3472 .on_action(cx.listener(|this, _: &search::FocusSearch, window, cx| {
3473 this.search_bar.focus_handle(cx).focus(window, cx);
3474 }))
3475 .on_action(cx.listener(|this, _: &ToggleFocusNav, window, cx| {
3476 if this
3477 .navbar_focus_handle
3478 .focus_handle(cx)
3479 .contains_focused(window, cx)
3480 {
3481 this.open_and_scroll_to_navbar_entry(
3482 this.navbar_entry,
3483 None,
3484 true,
3485 window,
3486 cx,
3487 );
3488 } else {
3489 this.focus_and_scroll_to_nav_entry(this.navbar_entry, window, cx);
3490 }
3491 }))
3492 .on_action(cx.listener(
3493 |this, FocusFile(file_index): &FocusFile, window, cx| {
3494 this.focus_file_at_index(*file_index as usize, window, cx);
3495 },
3496 ))
3497 .on_action(cx.listener(|this, _: &FocusNextFile, window, cx| {
3498 let next_index = usize::min(
3499 this.focused_file_index(window, cx) + 1,
3500 this.files.len().saturating_sub(1),
3501 );
3502 this.focus_file_at_index(next_index, window, cx);
3503 }))
3504 .on_action(cx.listener(|this, _: &FocusPreviousFile, window, cx| {
3505 let prev_index = this.focused_file_index(window, cx).saturating_sub(1);
3506 this.focus_file_at_index(prev_index, window, cx);
3507 }))
3508 .on_action(cx.listener(|this, _: &menu::SelectNext, window, cx| {
3509 if this
3510 .search_bar
3511 .focus_handle(cx)
3512 .contains_focused(window, cx)
3513 {
3514 this.focus_and_scroll_to_first_visible_nav_entry(window, cx);
3515 } else {
3516 window.focus_next(cx);
3517 }
3518 }))
3519 .on_action(|_: &menu::SelectPrevious, window, cx| {
3520 window.focus_prev(cx);
3521 })
3522 .flex()
3523 .flex_row()
3524 .flex_1()
3525 .min_h_0()
3526 .font(ui_font)
3527 .bg(cx.theme().colors().background)
3528 .text_color(cx.theme().colors().text)
3529 .when(!cfg!(target_os = "macos"), |this| {
3530 this.border_t_1().border_color(cx.theme().colors().border)
3531 })
3532 .child(self.render_nav(window, cx))
3533 .child(self.render_page(window, cx)),
3534 ),
3535 window,
3536 cx,
3537 )
3538 }
3539}
3540
3541fn all_projects(cx: &App) -> impl Iterator<Item = Entity<project::Project>> {
3542 workspace::AppState::global(cx)
3543 .upgrade()
3544 .map(|app_state| {
3545 app_state
3546 .workspace_store
3547 .read(cx)
3548 .workspaces()
3549 .iter()
3550 .filter_map(|workspace| Some(workspace.read(cx).ok()?.project().clone()))
3551 })
3552 .into_iter()
3553 .flatten()
3554}
3555
3556fn update_settings_file(
3557 file: SettingsUiFile,
3558 file_name: Option<&'static str>,
3559 cx: &mut App,
3560 update: impl 'static + Send + FnOnce(&mut SettingsContent, &App),
3561) -> Result<()> {
3562 telemetry::event!("Settings Change", setting = file_name, type = file.setting_type());
3563
3564 match file {
3565 SettingsUiFile::Project((worktree_id, rel_path)) => {
3566 let rel_path = rel_path.join(paths::local_settings_file_relative_path());
3567 let Some((worktree, project)) = all_projects(cx).find_map(|project| {
3568 project
3569 .read(cx)
3570 .worktree_for_id(worktree_id, cx)
3571 .zip(Some(project))
3572 }) else {
3573 anyhow::bail!("Could not find project with worktree id: {}", worktree_id);
3574 };
3575
3576 project.update(cx, |project, cx| {
3577 let task = if project.contains_local_settings_file(worktree_id, &rel_path, cx) {
3578 None
3579 } else {
3580 Some(worktree.update(cx, |worktree, cx| {
3581 worktree.create_entry(rel_path.clone(), false, None, cx)
3582 }))
3583 };
3584
3585 cx.spawn(async move |project, cx| {
3586 if let Some(task) = task
3587 && task.await.is_err()
3588 {
3589 return;
3590 };
3591
3592 project
3593 .update(cx, |project, cx| {
3594 project.update_local_settings_file(worktree_id, rel_path, cx, update);
3595 })
3596 .ok();
3597 })
3598 .detach();
3599 });
3600
3601 return Ok(());
3602 }
3603 SettingsUiFile::User => {
3604 // todo(settings_ui) error?
3605 SettingsStore::global(cx).update_settings_file(<dyn fs::Fs>::global(cx), update);
3606 Ok(())
3607 }
3608 SettingsUiFile::Server(_) => unimplemented!(),
3609 }
3610}
3611
3612fn render_text_field<T: From<String> + Into<String> + AsRef<str> + Clone>(
3613 field: SettingField<T>,
3614 file: SettingsUiFile,
3615 metadata: Option<&SettingsFieldMetadata>,
3616 _window: &mut Window,
3617 cx: &mut App,
3618) -> AnyElement {
3619 let (_, initial_text) =
3620 SettingsStore::global(cx).get_value_from_file(file.to_settings(), field.pick);
3621 let initial_text = initial_text.filter(|s| !s.as_ref().is_empty());
3622
3623 SettingsInputField::new()
3624 .tab_index(0)
3625 .when_some(initial_text, |editor, text| {
3626 editor.with_initial_text(text.as_ref().to_string())
3627 })
3628 .when_some(
3629 metadata.and_then(|metadata| metadata.placeholder),
3630 |editor, placeholder| editor.with_placeholder(placeholder),
3631 )
3632 .on_confirm({
3633 move |new_text, cx| {
3634 update_settings_file(file.clone(), field.json_path, cx, move |settings, _cx| {
3635 (field.write)(settings, new_text.map(Into::into));
3636 })
3637 .log_err(); // todo(settings_ui) don't log err
3638 }
3639 })
3640 .into_any_element()
3641}
3642
3643fn render_toggle_button<B: Into<bool> + From<bool> + Copy>(
3644 field: SettingField<B>,
3645 file: SettingsUiFile,
3646 _metadata: Option<&SettingsFieldMetadata>,
3647 _window: &mut Window,
3648 cx: &mut App,
3649) -> AnyElement {
3650 let (_, value) = SettingsStore::global(cx).get_value_from_file(file.to_settings(), field.pick);
3651
3652 let toggle_state = if value.copied().map_or(false, Into::into) {
3653 ToggleState::Selected
3654 } else {
3655 ToggleState::Unselected
3656 };
3657
3658 Switch::new("toggle_button", toggle_state)
3659 .tab_index(0_isize)
3660 .on_click({
3661 move |state, _window, cx| {
3662 telemetry::event!("Settings Change", setting = field.json_path, type = file.setting_type());
3663
3664 let state = *state == ui::ToggleState::Selected;
3665 update_settings_file(file.clone(), field.json_path, cx, move |settings, _cx| {
3666 (field.write)(settings, Some(state.into()));
3667 })
3668 .log_err(); // todo(settings_ui) don't log err
3669 }
3670 })
3671 .into_any_element()
3672}
3673
3674fn render_number_field<T: NumberFieldType + Send + Sync>(
3675 field: SettingField<T>,
3676 file: SettingsUiFile,
3677 _metadata: Option<&SettingsFieldMetadata>,
3678 window: &mut Window,
3679 cx: &mut App,
3680) -> AnyElement {
3681 let (_, value) = SettingsStore::global(cx).get_value_from_file(file.to_settings(), field.pick);
3682 let value = value.copied().unwrap_or_else(T::min_value);
3683 NumberField::new("numeric_stepper", value, window, cx)
3684 .on_change({
3685 move |value, _window, cx| {
3686 let value = *value;
3687 update_settings_file(file.clone(), field.json_path, cx, move |settings, _cx| {
3688 (field.write)(settings, Some(value));
3689 })
3690 .log_err(); // todo(settings_ui) don't log err
3691 }
3692 })
3693 .into_any_element()
3694}
3695
3696fn render_dropdown<T>(
3697 field: SettingField<T>,
3698 file: SettingsUiFile,
3699 metadata: Option<&SettingsFieldMetadata>,
3700 _window: &mut Window,
3701 cx: &mut App,
3702) -> AnyElement
3703where
3704 T: strum::VariantArray + strum::VariantNames + Copy + PartialEq + Send + Sync + 'static,
3705{
3706 let variants = || -> &'static [T] { <T as strum::VariantArray>::VARIANTS };
3707 let labels = || -> &'static [&'static str] { <T as strum::VariantNames>::VARIANTS };
3708 let should_do_titlecase = metadata
3709 .and_then(|metadata| metadata.should_do_titlecase)
3710 .unwrap_or(true);
3711
3712 let (_, current_value) =
3713 SettingsStore::global(cx).get_value_from_file(file.to_settings(), field.pick);
3714 let current_value = current_value.copied().unwrap_or(variants()[0]);
3715
3716 EnumVariantDropdown::new("dropdown", current_value, variants(), labels(), {
3717 move |value, cx| {
3718 if value == current_value {
3719 return;
3720 }
3721 update_settings_file(file.clone(), field.json_path, cx, move |settings, _cx| {
3722 (field.write)(settings, Some(value));
3723 })
3724 .log_err(); // todo(settings_ui) don't log err
3725 }
3726 })
3727 .tab_index(0)
3728 .title_case(should_do_titlecase)
3729 .into_any_element()
3730}
3731
3732fn render_picker_trigger_button(id: SharedString, label: SharedString) -> Button {
3733 Button::new(id, label)
3734 .tab_index(0_isize)
3735 .style(ButtonStyle::Outlined)
3736 .size(ButtonSize::Medium)
3737 .icon(IconName::ChevronUpDown)
3738 .icon_color(Color::Muted)
3739 .icon_size(IconSize::Small)
3740 .icon_position(IconPosition::End)
3741}
3742
3743fn render_font_picker(
3744 field: SettingField<settings::FontFamilyName>,
3745 file: SettingsUiFile,
3746 _metadata: Option<&SettingsFieldMetadata>,
3747 _window: &mut Window,
3748 cx: &mut App,
3749) -> AnyElement {
3750 let current_value = SettingsStore::global(cx)
3751 .get_value_from_file(file.to_settings(), field.pick)
3752 .1
3753 .cloned()
3754 .unwrap_or_else(|| SharedString::default().into());
3755
3756 PopoverMenu::new("font-picker")
3757 .trigger(render_picker_trigger_button(
3758 "font_family_picker_trigger".into(),
3759 current_value.clone().into(),
3760 ))
3761 .menu(move |window, cx| {
3762 let file = file.clone();
3763 let current_value = current_value.clone();
3764
3765 Some(cx.new(move |cx| {
3766 font_picker(
3767 current_value.clone().into(),
3768 move |font_name, cx| {
3769 update_settings_file(
3770 file.clone(),
3771 field.json_path,
3772 cx,
3773 move |settings, _cx| {
3774 (field.write)(settings, Some(font_name.into()));
3775 },
3776 )
3777 .log_err(); // todo(settings_ui) don't log err
3778 },
3779 window,
3780 cx,
3781 )
3782 }))
3783 })
3784 .anchor(gpui::Corner::TopLeft)
3785 .offset(gpui::Point {
3786 x: px(0.0),
3787 y: px(2.0),
3788 })
3789 .with_handle(ui::PopoverMenuHandle::default())
3790 .into_any_element()
3791}
3792
3793fn render_theme_picker(
3794 field: SettingField<settings::ThemeName>,
3795 file: SettingsUiFile,
3796 _metadata: Option<&SettingsFieldMetadata>,
3797 _window: &mut Window,
3798 cx: &mut App,
3799) -> AnyElement {
3800 let (_, value) = SettingsStore::global(cx).get_value_from_file(file.to_settings(), field.pick);
3801 let current_value = value
3802 .cloned()
3803 .map(|theme_name| theme_name.0.into())
3804 .unwrap_or_else(|| cx.theme().name.clone());
3805
3806 PopoverMenu::new("theme-picker")
3807 .trigger(render_picker_trigger_button(
3808 "theme_picker_trigger".into(),
3809 current_value.clone(),
3810 ))
3811 .menu(move |window, cx| {
3812 Some(cx.new(|cx| {
3813 let file = file.clone();
3814 let current_value = current_value.clone();
3815 theme_picker(
3816 current_value,
3817 move |theme_name, cx| {
3818 update_settings_file(
3819 file.clone(),
3820 field.json_path,
3821 cx,
3822 move |settings, _cx| {
3823 (field.write)(
3824 settings,
3825 Some(settings::ThemeName(theme_name.into())),
3826 );
3827 },
3828 )
3829 .log_err(); // todo(settings_ui) don't log err
3830 },
3831 window,
3832 cx,
3833 )
3834 }))
3835 })
3836 .anchor(gpui::Corner::TopLeft)
3837 .offset(gpui::Point {
3838 x: px(0.0),
3839 y: px(2.0),
3840 })
3841 .with_handle(ui::PopoverMenuHandle::default())
3842 .into_any_element()
3843}
3844
3845fn render_icon_theme_picker(
3846 field: SettingField<settings::IconThemeName>,
3847 file: SettingsUiFile,
3848 _metadata: Option<&SettingsFieldMetadata>,
3849 _window: &mut Window,
3850 cx: &mut App,
3851) -> AnyElement {
3852 let (_, value) = SettingsStore::global(cx).get_value_from_file(file.to_settings(), field.pick);
3853 let current_value = value
3854 .cloned()
3855 .map(|theme_name| theme_name.0.into())
3856 .unwrap_or_else(|| cx.theme().name.clone());
3857
3858 PopoverMenu::new("icon-theme-picker")
3859 .trigger(render_picker_trigger_button(
3860 "icon_theme_picker_trigger".into(),
3861 current_value.clone(),
3862 ))
3863 .menu(move |window, cx| {
3864 Some(cx.new(|cx| {
3865 let file = file.clone();
3866 let current_value = current_value.clone();
3867 icon_theme_picker(
3868 current_value,
3869 move |theme_name, cx| {
3870 update_settings_file(
3871 file.clone(),
3872 field.json_path,
3873 cx,
3874 move |settings, _cx| {
3875 (field.write)(
3876 settings,
3877 Some(settings::IconThemeName(theme_name.into())),
3878 );
3879 },
3880 )
3881 .log_err(); // todo(settings_ui) don't log err
3882 },
3883 window,
3884 cx,
3885 )
3886 }))
3887 })
3888 .anchor(gpui::Corner::TopLeft)
3889 .offset(gpui::Point {
3890 x: px(0.0),
3891 y: px(2.0),
3892 })
3893 .with_handle(ui::PopoverMenuHandle::default())
3894 .into_any_element()
3895}
3896
3897#[cfg(test)]
3898pub mod test {
3899
3900 use super::*;
3901
3902 impl SettingsWindow {
3903 fn navbar_entry(&self) -> usize {
3904 self.navbar_entry
3905 }
3906 }
3907
3908 impl PartialEq for NavBarEntry {
3909 fn eq(&self, other: &Self) -> bool {
3910 self.title == other.title
3911 && self.is_root == other.is_root
3912 && self.expanded == other.expanded
3913 && self.page_index == other.page_index
3914 && self.item_index == other.item_index
3915 // ignoring focus_handle
3916 }
3917 }
3918
3919 pub fn register_settings(cx: &mut App) {
3920 settings::init(cx);
3921 theme::init(theme::LoadThemes::JustBase, cx);
3922 editor::init(cx);
3923 menu::init();
3924 }
3925
3926 fn parse(input: &'static str, window: &mut Window, cx: &mut App) -> SettingsWindow {
3927 let mut pages: Vec<SettingsPage> = Vec::new();
3928 let mut expanded_pages = Vec::new();
3929 let mut selected_idx = None;
3930 let mut index = 0;
3931 let mut in_expanded_section = false;
3932
3933 for mut line in input
3934 .lines()
3935 .map(|line| line.trim())
3936 .filter(|line| !line.is_empty())
3937 {
3938 if let Some(pre) = line.strip_suffix('*') {
3939 assert!(selected_idx.is_none(), "Only one selected entry allowed");
3940 selected_idx = Some(index);
3941 line = pre;
3942 }
3943 let (kind, title) = line.split_once(" ").unwrap();
3944 assert_eq!(kind.len(), 1);
3945 let kind = kind.chars().next().unwrap();
3946 if kind == 'v' {
3947 let page_idx = pages.len();
3948 expanded_pages.push(page_idx);
3949 pages.push(SettingsPage {
3950 title,
3951 items: vec![],
3952 });
3953 index += 1;
3954 in_expanded_section = true;
3955 } else if kind == '>' {
3956 pages.push(SettingsPage {
3957 title,
3958 items: vec![],
3959 });
3960 index += 1;
3961 in_expanded_section = false;
3962 } else if kind == '-' {
3963 pages
3964 .last_mut()
3965 .unwrap()
3966 .items
3967 .push(SettingsPageItem::SectionHeader(title));
3968 if selected_idx == Some(index) && !in_expanded_section {
3969 panic!("Items in unexpanded sections cannot be selected");
3970 }
3971 index += 1;
3972 } else {
3973 panic!(
3974 "Entries must start with one of 'v', '>', or '-'\n line: {}",
3975 line
3976 );
3977 }
3978 }
3979
3980 let mut settings_window = SettingsWindow {
3981 title_bar: None,
3982 original_window: None,
3983 worktree_root_dirs: HashMap::default(),
3984 files: Vec::default(),
3985 current_file: crate::SettingsUiFile::User,
3986 pages,
3987 search_bar: cx.new(|cx| Editor::single_line(window, cx)),
3988 navbar_entry: selected_idx.expect("Must have a selected navbar entry"),
3989 navbar_entries: Vec::default(),
3990 navbar_scroll_handle: UniformListScrollHandle::default(),
3991 navbar_focus_subscriptions: vec![],
3992 filter_table: vec![],
3993 has_query: false,
3994 content_handles: vec![],
3995 search_task: None,
3996 sub_page_scroll_handle: ScrollHandle::new(),
3997 focus_handle: cx.focus_handle(),
3998 navbar_focus_handle: NonFocusableHandle::new(
3999 NAVBAR_CONTAINER_TAB_INDEX,
4000 false,
4001 window,
4002 cx,
4003 ),
4004 content_focus_handle: NonFocusableHandle::new(
4005 CONTENT_CONTAINER_TAB_INDEX,
4006 false,
4007 window,
4008 cx,
4009 ),
4010 files_focus_handle: cx.focus_handle(),
4011 search_index: None,
4012 list_state: ListState::new(0, gpui::ListAlignment::Top, px(0.0)),
4013 shown_errors: HashSet::default(),
4014 };
4015
4016 settings_window.build_filter_table();
4017 settings_window.build_navbar(cx);
4018 for expanded_page_index in expanded_pages {
4019 for entry in &mut settings_window.navbar_entries {
4020 if entry.page_index == expanded_page_index && entry.is_root {
4021 entry.expanded = true;
4022 }
4023 }
4024 }
4025 settings_window
4026 }
4027
4028 #[track_caller]
4029 fn check_navbar_toggle(
4030 before: &'static str,
4031 toggle_page: &'static str,
4032 after: &'static str,
4033 window: &mut Window,
4034 cx: &mut App,
4035 ) {
4036 let mut settings_window = parse(before, window, cx);
4037 let toggle_page_idx = settings_window
4038 .pages
4039 .iter()
4040 .position(|page| page.title == toggle_page)
4041 .expect("page not found");
4042 let toggle_idx = settings_window
4043 .navbar_entries
4044 .iter()
4045 .position(|entry| entry.page_index == toggle_page_idx)
4046 .expect("page not found");
4047 settings_window.toggle_navbar_entry(toggle_idx);
4048
4049 let expected_settings_window = parse(after, window, cx);
4050
4051 pretty_assertions::assert_eq!(
4052 settings_window
4053 .visible_navbar_entries()
4054 .map(|(_, entry)| entry)
4055 .collect::<Vec<_>>(),
4056 expected_settings_window
4057 .visible_navbar_entries()
4058 .map(|(_, entry)| entry)
4059 .collect::<Vec<_>>(),
4060 );
4061 pretty_assertions::assert_eq!(
4062 settings_window.navbar_entries[settings_window.navbar_entry()],
4063 expected_settings_window.navbar_entries[expected_settings_window.navbar_entry()],
4064 );
4065 }
4066
4067 macro_rules! check_navbar_toggle {
4068 ($name:ident, before: $before:expr, toggle_page: $toggle_page:expr, after: $after:expr) => {
4069 #[gpui::test]
4070 fn $name(cx: &mut gpui::TestAppContext) {
4071 let window = cx.add_empty_window();
4072 window.update(|window, cx| {
4073 register_settings(cx);
4074 check_navbar_toggle($before, $toggle_page, $after, window, cx);
4075 });
4076 }
4077 };
4078 }
4079
4080 check_navbar_toggle!(
4081 navbar_basic_open,
4082 before: r"
4083 v General
4084 - General
4085 - Privacy*
4086 v Project
4087 - Project Settings
4088 ",
4089 toggle_page: "General",
4090 after: r"
4091 > General*
4092 v Project
4093 - Project Settings
4094 "
4095 );
4096
4097 check_navbar_toggle!(
4098 navbar_basic_close,
4099 before: r"
4100 > General*
4101 - General
4102 - Privacy
4103 v Project
4104 - Project Settings
4105 ",
4106 toggle_page: "General",
4107 after: r"
4108 v General*
4109 - General
4110 - Privacy
4111 v Project
4112 - Project Settings
4113 "
4114 );
4115
4116 check_navbar_toggle!(
4117 navbar_basic_second_root_entry_close,
4118 before: r"
4119 > General
4120 - General
4121 - Privacy
4122 v Project
4123 - Project Settings*
4124 ",
4125 toggle_page: "Project",
4126 after: r"
4127 > General
4128 > Project*
4129 "
4130 );
4131
4132 check_navbar_toggle!(
4133 navbar_toggle_subroot,
4134 before: r"
4135 v General Page
4136 - General
4137 - Privacy
4138 v Project
4139 - Worktree Settings Content*
4140 v AI
4141 - General
4142 > Appearance & Behavior
4143 ",
4144 toggle_page: "Project",
4145 after: r"
4146 v General Page
4147 - General
4148 - Privacy
4149 > Project*
4150 v AI
4151 - General
4152 > Appearance & Behavior
4153 "
4154 );
4155
4156 check_navbar_toggle!(
4157 navbar_toggle_close_propagates_selected_index,
4158 before: r"
4159 v General Page
4160 - General
4161 - Privacy
4162 v Project
4163 - Worktree Settings Content
4164 v AI
4165 - General*
4166 > Appearance & Behavior
4167 ",
4168 toggle_page: "General Page",
4169 after: r"
4170 > General Page*
4171 v Project
4172 - Worktree Settings Content
4173 v AI
4174 - General
4175 > Appearance & Behavior
4176 "
4177 );
4178
4179 check_navbar_toggle!(
4180 navbar_toggle_expand_propagates_selected_index,
4181 before: r"
4182 > General Page
4183 - General
4184 - Privacy
4185 v Project
4186 - Worktree Settings Content
4187 v AI
4188 - General*
4189 > Appearance & Behavior
4190 ",
4191 toggle_page: "General Page",
4192 after: r"
4193 v General Page*
4194 - General
4195 - Privacy
4196 v Project
4197 - Worktree Settings Content
4198 v AI
4199 - General
4200 > Appearance & Behavior
4201 "
4202 );
4203}