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, _| {
350 window.focus_next();
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, _, _, 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, 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);
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);
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);
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);
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 );
2551 cx.notify();
2552 },
2553 ))
2554 })
2555 .on_click({
2556 let category = this.pages[entry.page_index].title;
2557 let subcategory =
2558 (!entry.is_root).then_some(entry.title);
2559
2560 cx.listener(move |this, _, window, cx| {
2561 telemetry::event!(
2562 "Settings Navigation Clicked",
2563 category = category,
2564 subcategory = subcategory
2565 );
2566
2567 this.open_and_scroll_to_navbar_entry(
2568 entry_index,
2569 None,
2570 true,
2571 window,
2572 cx,
2573 );
2574 })
2575 })
2576 })
2577 .collect()
2578 }),
2579 )
2580 .size_full()
2581 .track_scroll(&self.navbar_scroll_handle),
2582 )
2583 .vertical_scrollbar_for(&self.navbar_scroll_handle, window, cx),
2584 )
2585 .child(
2586 h_flex()
2587 .w_full()
2588 .h_8()
2589 .p_2()
2590 .pb_0p5()
2591 .flex_shrink_0()
2592 .border_t_1()
2593 .border_color(cx.theme().colors().border_variant)
2594 .child(
2595 KeybindingHint::new(
2596 KeyBinding::for_action_in(
2597 &ToggleFocusNav,
2598 &self.navbar_focus_handle.focus_handle(cx),
2599 cx,
2600 ),
2601 cx.theme().colors().surface_background.opacity(0.5),
2602 )
2603 .suffix(focus_keybind_label),
2604 ),
2605 )
2606 }
2607
2608 fn open_and_scroll_to_navbar_entry(
2609 &mut self,
2610 navbar_entry_index: usize,
2611 scroll_strategy: Option<gpui::ScrollStrategy>,
2612 focus_content: bool,
2613 window: &mut Window,
2614 cx: &mut Context<Self>,
2615 ) {
2616 self.open_navbar_entry_page(navbar_entry_index);
2617 cx.notify();
2618
2619 let mut handle_to_focus = None;
2620
2621 if self.navbar_entries[navbar_entry_index].is_root
2622 || !self.is_nav_entry_visible(navbar_entry_index)
2623 {
2624 self.sub_page_scroll_handle
2625 .set_offset(point(px(0.), px(0.)));
2626 if focus_content {
2627 let Some(first_item_index) =
2628 self.visible_page_items().next().map(|(index, _)| index)
2629 else {
2630 return;
2631 };
2632 handle_to_focus = Some(self.focus_handle_for_content_element(first_item_index, cx));
2633 } else if !self.is_nav_entry_visible(navbar_entry_index) {
2634 let Some(first_visible_nav_entry_index) =
2635 self.visible_navbar_entries().next().map(|(index, _)| index)
2636 else {
2637 return;
2638 };
2639 self.focus_and_scroll_to_nav_entry(first_visible_nav_entry_index, window, cx);
2640 } else {
2641 handle_to_focus =
2642 Some(self.navbar_entries[navbar_entry_index].focus_handle.clone());
2643 }
2644 } else {
2645 let entry_item_index = self.navbar_entries[navbar_entry_index]
2646 .item_index
2647 .expect("Non-root items should have an item index");
2648 self.scroll_to_content_item(entry_item_index, window, cx);
2649 if focus_content {
2650 handle_to_focus = Some(self.focus_handle_for_content_element(entry_item_index, cx));
2651 } else {
2652 handle_to_focus =
2653 Some(self.navbar_entries[navbar_entry_index].focus_handle.clone());
2654 }
2655 }
2656
2657 if let Some(scroll_strategy) = scroll_strategy
2658 && let Some(logical_entry_index) = self
2659 .visible_navbar_entries()
2660 .into_iter()
2661 .position(|(index, _)| index == navbar_entry_index)
2662 {
2663 self.navbar_scroll_handle
2664 .scroll_to_item(logical_entry_index + 1, scroll_strategy);
2665 }
2666
2667 // Page scroll handle updates the active item index
2668 // in it's next paint call after using scroll_handle.scroll_to_top_of_item
2669 // The call after that updates the offset of the scroll handle. So to
2670 // ensure the scroll handle doesn't lag behind we need to render three frames
2671 // back to back.
2672 cx.on_next_frame(window, move |_, window, cx| {
2673 if let Some(handle) = handle_to_focus.as_ref() {
2674 window.focus(handle);
2675 }
2676
2677 cx.on_next_frame(window, |_, _, cx| {
2678 cx.notify();
2679 });
2680 cx.notify();
2681 });
2682 cx.notify();
2683 }
2684
2685 fn scroll_to_content_item(
2686 &self,
2687 content_item_index: usize,
2688 _window: &mut Window,
2689 cx: &mut Context<Self>,
2690 ) {
2691 let index = self
2692 .visible_page_items()
2693 .position(|(index, _)| index == content_item_index)
2694 .unwrap_or(0);
2695 if index == 0 {
2696 self.sub_page_scroll_handle
2697 .set_offset(point(px(0.), px(0.)));
2698 self.list_state.scroll_to(gpui::ListOffset {
2699 item_ix: 0,
2700 offset_in_item: px(0.),
2701 });
2702 return;
2703 }
2704 self.list_state.scroll_to(gpui::ListOffset {
2705 item_ix: index + 1,
2706 offset_in_item: px(0.),
2707 });
2708 cx.notify();
2709 }
2710
2711 fn is_nav_entry_visible(&self, nav_entry_index: usize) -> bool {
2712 self.visible_navbar_entries()
2713 .any(|(index, _)| index == nav_entry_index)
2714 }
2715
2716 fn focus_and_scroll_to_first_visible_nav_entry(
2717 &self,
2718 window: &mut Window,
2719 cx: &mut Context<Self>,
2720 ) {
2721 if let Some(nav_entry_index) = self.visible_navbar_entries().next().map(|(index, _)| index)
2722 {
2723 self.focus_and_scroll_to_nav_entry(nav_entry_index, window, cx);
2724 }
2725 }
2726
2727 fn focus_and_scroll_to_nav_entry(
2728 &self,
2729 nav_entry_index: usize,
2730 window: &mut Window,
2731 cx: &mut Context<Self>,
2732 ) {
2733 let Some(position) = self
2734 .visible_navbar_entries()
2735 .position(|(index, _)| index == nav_entry_index)
2736 else {
2737 return;
2738 };
2739 self.navbar_scroll_handle
2740 .scroll_to_item(position, gpui::ScrollStrategy::Top);
2741 window.focus(&self.navbar_entries[nav_entry_index].focus_handle);
2742 cx.notify();
2743 }
2744
2745 fn visible_page_items(&self) -> impl Iterator<Item = (usize, &SettingsPageItem)> {
2746 let page_idx = self.current_page_index();
2747
2748 self.current_page()
2749 .items
2750 .iter()
2751 .enumerate()
2752 .filter_map(move |(item_index, item)| {
2753 self.filter_table[page_idx][item_index].then_some((item_index, item))
2754 })
2755 }
2756
2757 fn render_sub_page_breadcrumbs(&self) -> impl IntoElement {
2758 let mut items = vec![];
2759 items.push(self.current_page().title.into());
2760 items.extend(
2761 sub_page_stack()
2762 .iter()
2763 .flat_map(|page| [page.section_header.into(), page.link.title.clone()]),
2764 );
2765
2766 let last = items.pop().unwrap();
2767 h_flex()
2768 .gap_1()
2769 .children(
2770 items
2771 .into_iter()
2772 .flat_map(|item| [item, "/".into()])
2773 .map(|item| Label::new(item).color(Color::Muted)),
2774 )
2775 .child(Label::new(last))
2776 }
2777
2778 fn render_empty_state(&self, search_query: SharedString) -> impl IntoElement {
2779 v_flex()
2780 .size_full()
2781 .items_center()
2782 .justify_center()
2783 .gap_1()
2784 .child(Label::new("No Results"))
2785 .child(
2786 Label::new(search_query)
2787 .size(LabelSize::Small)
2788 .color(Color::Muted),
2789 )
2790 }
2791
2792 fn render_page_items(
2793 &mut self,
2794 page_index: usize,
2795 _window: &mut Window,
2796 cx: &mut Context<SettingsWindow>,
2797 ) -> impl IntoElement {
2798 let mut page_content = v_flex().id("settings-ui-page").size_full();
2799
2800 let has_active_search = !self.search_bar.read(cx).is_empty(cx);
2801 let has_no_results = self.visible_page_items().next().is_none() && has_active_search;
2802
2803 if has_no_results {
2804 let search_query = self.search_bar.read(cx).text(cx);
2805 page_content = page_content.child(
2806 self.render_empty_state(format!("No settings match \"{}\"", search_query).into()),
2807 )
2808 } else {
2809 let last_non_header_index = self
2810 .visible_page_items()
2811 .filter_map(|(index, item)| {
2812 (!matches!(item, SettingsPageItem::SectionHeader(_))).then_some(index)
2813 })
2814 .last();
2815
2816 let root_nav_label = self
2817 .navbar_entries
2818 .iter()
2819 .find(|entry| entry.is_root && entry.page_index == self.current_page_index())
2820 .map(|entry| entry.title);
2821
2822 let list_content = list(
2823 self.list_state.clone(),
2824 cx.processor(move |this, index, window, cx| {
2825 if index == 0 {
2826 return div()
2827 .px_8()
2828 .when(sub_page_stack().is_empty(), |this| {
2829 this.when_some(root_nav_label, |this, title| {
2830 this.child(
2831 Label::new(title).size(LabelSize::Large).mt_2().mb_3(),
2832 )
2833 })
2834 })
2835 .into_any_element();
2836 }
2837
2838 let mut visible_items = this.visible_page_items();
2839 let Some((actual_item_index, item)) = visible_items.nth(index - 1) else {
2840 return gpui::Empty.into_any_element();
2841 };
2842
2843 let no_bottom_border = visible_items
2844 .next()
2845 .map(|(_, item)| matches!(item, SettingsPageItem::SectionHeader(_)))
2846 .unwrap_or(false);
2847
2848 let is_last = Some(actual_item_index) == last_non_header_index;
2849
2850 let item_focus_handle =
2851 this.content_handles[page_index][actual_item_index].focus_handle(cx);
2852
2853 v_flex()
2854 .id(("settings-page-item", actual_item_index))
2855 .track_focus(&item_focus_handle)
2856 .w_full()
2857 .min_w_0()
2858 .child(item.render(
2859 this,
2860 actual_item_index,
2861 no_bottom_border || is_last,
2862 window,
2863 cx,
2864 ))
2865 .into_any_element()
2866 }),
2867 );
2868
2869 page_content = page_content.child(list_content.size_full())
2870 }
2871 page_content
2872 }
2873
2874 fn render_sub_page_items<'a, Items>(
2875 &self,
2876 items: Items,
2877 page_index: Option<usize>,
2878 window: &mut Window,
2879 cx: &mut Context<SettingsWindow>,
2880 ) -> impl IntoElement
2881 where
2882 Items: Iterator<Item = (usize, &'a SettingsPageItem)>,
2883 {
2884 let page_content = v_flex()
2885 .id("settings-ui-page")
2886 .size_full()
2887 .overflow_y_scroll()
2888 .track_scroll(&self.sub_page_scroll_handle);
2889 self.render_sub_page_items_in(page_content, items, page_index, window, cx)
2890 }
2891
2892 fn render_sub_page_items_section<'a, Items>(
2893 &self,
2894 items: Items,
2895 page_index: Option<usize>,
2896 window: &mut Window,
2897 cx: &mut Context<SettingsWindow>,
2898 ) -> impl IntoElement
2899 where
2900 Items: Iterator<Item = (usize, &'a SettingsPageItem)>,
2901 {
2902 let page_content = v_flex().id("settings-ui-sub-page-section").size_full();
2903 self.render_sub_page_items_in(page_content, items, page_index, window, cx)
2904 }
2905
2906 fn render_sub_page_items_in<'a, Items>(
2907 &self,
2908 mut page_content: Stateful<Div>,
2909 items: Items,
2910 page_index: Option<usize>,
2911 window: &mut Window,
2912 cx: &mut Context<SettingsWindow>,
2913 ) -> impl IntoElement
2914 where
2915 Items: Iterator<Item = (usize, &'a SettingsPageItem)>,
2916 {
2917 let items: Vec<_> = items.collect();
2918 let items_len = items.len();
2919 let mut section_header = None;
2920
2921 let has_active_search = !self.search_bar.read(cx).is_empty(cx);
2922 let has_no_results = items_len == 0 && has_active_search;
2923
2924 if has_no_results {
2925 let search_query = self.search_bar.read(cx).text(cx);
2926 page_content = page_content.child(
2927 self.render_empty_state(format!("No settings match \"{}\"", search_query).into()),
2928 )
2929 } else {
2930 let last_non_header_index = items
2931 .iter()
2932 .enumerate()
2933 .rev()
2934 .find(|(_, (_, item))| !matches!(item, SettingsPageItem::SectionHeader(_)))
2935 .map(|(index, _)| index);
2936
2937 let root_nav_label = self
2938 .navbar_entries
2939 .iter()
2940 .find(|entry| entry.is_root && entry.page_index == self.current_page_index())
2941 .map(|entry| entry.title);
2942
2943 page_content = page_content
2944 .when(sub_page_stack().is_empty(), |this| {
2945 this.when_some(root_nav_label, |this, title| {
2946 this.child(Label::new(title).size(LabelSize::Large).mt_2().mb_3())
2947 })
2948 })
2949 .children(items.clone().into_iter().enumerate().map(
2950 |(index, (actual_item_index, item))| {
2951 let no_bottom_border = items
2952 .get(index + 1)
2953 .map(|(_, next_item)| {
2954 matches!(next_item, SettingsPageItem::SectionHeader(_))
2955 })
2956 .unwrap_or(false);
2957 let is_last = Some(index) == last_non_header_index;
2958
2959 if let SettingsPageItem::SectionHeader(header) = item {
2960 section_header = Some(*header);
2961 }
2962 v_flex()
2963 .w_full()
2964 .min_w_0()
2965 .id(("settings-page-item", actual_item_index))
2966 .when_some(page_index, |element, page_index| {
2967 element.track_focus(
2968 &self.content_handles[page_index][actual_item_index]
2969 .focus_handle(cx),
2970 )
2971 })
2972 .child(item.render(
2973 self,
2974 actual_item_index,
2975 no_bottom_border || is_last,
2976 window,
2977 cx,
2978 ))
2979 },
2980 ))
2981 }
2982 page_content
2983 }
2984
2985 fn render_page(
2986 &mut self,
2987 window: &mut Window,
2988 cx: &mut Context<SettingsWindow>,
2989 ) -> impl IntoElement {
2990 let page_header;
2991 let page_content;
2992
2993 if sub_page_stack().is_empty() {
2994 page_header = self.render_files_header(window, cx).into_any_element();
2995
2996 page_content = self
2997 .render_page_items(self.current_page_index(), window, cx)
2998 .into_any_element();
2999 } else {
3000 page_header = h_flex()
3001 .w_full()
3002 .justify_between()
3003 .child(
3004 h_flex()
3005 .ml_neg_1p5()
3006 .gap_1()
3007 .child(
3008 IconButton::new("back-btn", IconName::ArrowLeft)
3009 .icon_size(IconSize::Small)
3010 .shape(IconButtonShape::Square)
3011 .on_click(cx.listener(|this, _, _, cx| {
3012 this.pop_sub_page(cx);
3013 })),
3014 )
3015 .child(self.render_sub_page_breadcrumbs()),
3016 )
3017 .when(
3018 sub_page_stack()
3019 .last()
3020 .is_none_or(|sub_page| sub_page.link.in_json),
3021 |this| {
3022 this.child(
3023 Button::new("open-in-settings-file", "Edit in settings.json")
3024 .tab_index(0_isize)
3025 .style(ButtonStyle::OutlinedGhost)
3026 .tooltip(Tooltip::for_action_title_in(
3027 "Edit in settings.json",
3028 &OpenCurrentFile,
3029 &self.focus_handle,
3030 ))
3031 .on_click(cx.listener(|this, _, window, cx| {
3032 this.open_current_settings_file(window, cx);
3033 })),
3034 )
3035 },
3036 )
3037 .into_any_element();
3038
3039 let active_page_render_fn = sub_page_stack().last().unwrap().link.render.clone();
3040 page_content = (active_page_render_fn)(self, window, cx);
3041 }
3042
3043 let mut warning_banner = gpui::Empty.into_any_element();
3044 if let Some(error) =
3045 SettingsStore::global(cx).error_for_file(self.current_file.to_settings())
3046 {
3047 fn banner(
3048 label: &'static str,
3049 error: String,
3050 shown_errors: &mut HashSet<String>,
3051 cx: &mut Context<SettingsWindow>,
3052 ) -> impl IntoElement {
3053 if shown_errors.insert(error.clone()) {
3054 telemetry::event!("Settings Error Shown", label = label, error = &error);
3055 }
3056 Banner::new()
3057 .severity(Severity::Warning)
3058 .child(
3059 v_flex()
3060 .my_0p5()
3061 .gap_0p5()
3062 .child(Label::new(label))
3063 .child(Label::new(error).size(LabelSize::Small).color(Color::Muted)),
3064 )
3065 .action_slot(
3066 div().pr_1().pb_1().child(
3067 Button::new("fix-in-json", "Fix in settings.json")
3068 .tab_index(0_isize)
3069 .style(ButtonStyle::Tinted(ui::TintColor::Warning))
3070 .on_click(cx.listener(|this, _, window, cx| {
3071 this.open_current_settings_file(window, cx);
3072 })),
3073 ),
3074 )
3075 }
3076
3077 let parse_error = error.parse_error();
3078 let parse_failed = parse_error.is_some();
3079
3080 warning_banner = v_flex()
3081 .gap_2()
3082 .when_some(parse_error, |this, err| {
3083 this.child(banner(
3084 "Failed to load your settings. Some values may be incorrect and changes may be lost.",
3085 err,
3086 &mut self.shown_errors,
3087 cx,
3088 ))
3089 })
3090 .map(|this| match &error.migration_status {
3091 settings::MigrationStatus::Succeeded => this.child(banner(
3092 "Your settings are out of date, and need to be updated.",
3093 match &self.current_file {
3094 SettingsUiFile::User => "They can be automatically migrated to the latest version.",
3095 SettingsUiFile::Server(_) | SettingsUiFile::Project(_) => "They must be manually migrated to the latest version."
3096 }.to_string(),
3097 &mut self.shown_errors,
3098 cx,
3099 )),
3100 settings::MigrationStatus::Failed { error: err } if !parse_failed => this
3101 .child(banner(
3102 "Your settings file is out of date, automatic migration failed",
3103 err.clone(),
3104 &mut self.shown_errors,
3105 cx,
3106 )),
3107 _ => this,
3108 })
3109 .into_any_element()
3110 }
3111
3112 return v_flex()
3113 .id("settings-ui-page")
3114 .on_action(cx.listener(|this, _: &menu::SelectNext, window, cx| {
3115 if !sub_page_stack().is_empty() {
3116 window.focus_next();
3117 return;
3118 }
3119 for (logical_index, (actual_index, _)) in this.visible_page_items().enumerate() {
3120 let handle = this.content_handles[this.current_page_index()][actual_index]
3121 .focus_handle(cx);
3122 let mut offset = 1; // for page header
3123
3124 if let Some((_, next_item)) = this.visible_page_items().nth(logical_index + 1)
3125 && matches!(next_item, SettingsPageItem::SectionHeader(_))
3126 {
3127 offset += 1;
3128 }
3129 if handle.contains_focused(window, cx) {
3130 let next_logical_index = logical_index + offset + 1;
3131 this.list_state.scroll_to_reveal_item(next_logical_index);
3132 // We need to render the next item to ensure it's focus handle is in the element tree
3133 cx.on_next_frame(window, |_, window, cx| {
3134 cx.notify();
3135 cx.on_next_frame(window, |_, window, cx| {
3136 window.focus_next();
3137 cx.notify();
3138 });
3139 });
3140 cx.notify();
3141 return;
3142 }
3143 }
3144 window.focus_next();
3145 }))
3146 .on_action(cx.listener(|this, _: &menu::SelectPrevious, window, cx| {
3147 if !sub_page_stack().is_empty() {
3148 window.focus_prev();
3149 return;
3150 }
3151 let mut prev_was_header = false;
3152 for (logical_index, (actual_index, item)) in this.visible_page_items().enumerate() {
3153 let is_header = matches!(item, SettingsPageItem::SectionHeader(_));
3154 let handle = this.content_handles[this.current_page_index()][actual_index]
3155 .focus_handle(cx);
3156 let mut offset = 1; // for page header
3157
3158 if prev_was_header {
3159 offset -= 1;
3160 }
3161 if handle.contains_focused(window, cx) {
3162 let next_logical_index = logical_index + offset - 1;
3163 this.list_state.scroll_to_reveal_item(next_logical_index);
3164 // We need to render the next item to ensure it's focus handle is in the element tree
3165 cx.on_next_frame(window, |_, window, cx| {
3166 cx.notify();
3167 cx.on_next_frame(window, |_, window, cx| {
3168 window.focus_prev();
3169 cx.notify();
3170 });
3171 });
3172 cx.notify();
3173 return;
3174 }
3175 prev_was_header = is_header;
3176 }
3177 window.focus_prev();
3178 }))
3179 .when(sub_page_stack().is_empty(), |this| {
3180 this.vertical_scrollbar_for(&self.list_state, window, cx)
3181 })
3182 .when(!sub_page_stack().is_empty(), |this| {
3183 this.vertical_scrollbar_for(&self.sub_page_scroll_handle, window, cx)
3184 })
3185 .track_focus(&self.content_focus_handle.focus_handle(cx))
3186 .pt_6()
3187 .gap_4()
3188 .flex_1()
3189 .bg(cx.theme().colors().editor_background)
3190 .child(
3191 v_flex()
3192 .px_8()
3193 .gap_2()
3194 .child(page_header)
3195 .child(warning_banner),
3196 )
3197 .child(
3198 div()
3199 .flex_1()
3200 .size_full()
3201 .tab_group()
3202 .tab_index(CONTENT_GROUP_TAB_INDEX)
3203 .child(page_content),
3204 );
3205 }
3206
3207 /// This function will create a new settings file if one doesn't exist
3208 /// if the current file is a project settings with a valid worktree id
3209 /// We do this because the settings ui allows initializing project settings
3210 fn open_current_settings_file(&mut self, window: &mut Window, cx: &mut Context<Self>) {
3211 match &self.current_file {
3212 SettingsUiFile::User => {
3213 let Some(original_window) = self.original_window else {
3214 return;
3215 };
3216 original_window
3217 .update(cx, |workspace, window, cx| {
3218 workspace
3219 .with_local_workspace(window, cx, |workspace, window, cx| {
3220 let create_task = workspace.project().update(cx, |project, cx| {
3221 project.find_or_create_worktree(
3222 paths::config_dir().as_path(),
3223 false,
3224 cx,
3225 )
3226 });
3227 let open_task = workspace.open_paths(
3228 vec![paths::settings_file().to_path_buf()],
3229 OpenOptions {
3230 visible: Some(OpenVisible::None),
3231 ..Default::default()
3232 },
3233 None,
3234 window,
3235 cx,
3236 );
3237
3238 cx.spawn_in(window, async move |workspace, cx| {
3239 create_task.await.ok();
3240 open_task.await;
3241
3242 workspace.update_in(cx, |_, window, cx| {
3243 window.activate_window();
3244 cx.notify();
3245 })
3246 })
3247 .detach();
3248 })
3249 .detach();
3250 })
3251 .ok();
3252
3253 window.remove_window();
3254 }
3255 SettingsUiFile::Project((worktree_id, path)) => {
3256 let settings_path = path.join(paths::local_settings_file_relative_path());
3257 let Some(app_state) = workspace::AppState::global(cx).upgrade() else {
3258 return;
3259 };
3260
3261 let Some((worktree, corresponding_workspace)) = app_state
3262 .workspace_store
3263 .read(cx)
3264 .workspaces()
3265 .iter()
3266 .find_map(|workspace| {
3267 workspace
3268 .read_with(cx, |workspace, cx| {
3269 workspace
3270 .project()
3271 .read(cx)
3272 .worktree_for_id(*worktree_id, cx)
3273 })
3274 .ok()
3275 .flatten()
3276 .zip(Some(*workspace))
3277 })
3278 else {
3279 log::error!(
3280 "No corresponding workspace contains worktree id: {}",
3281 worktree_id
3282 );
3283
3284 return;
3285 };
3286
3287 let create_task = if worktree.read(cx).entry_for_path(&settings_path).is_some() {
3288 None
3289 } else {
3290 Some(worktree.update(cx, |tree, cx| {
3291 tree.create_entry(
3292 settings_path.clone(),
3293 false,
3294 Some(initial_project_settings_content().as_bytes().to_vec()),
3295 cx,
3296 )
3297 }))
3298 };
3299
3300 let worktree_id = *worktree_id;
3301
3302 // TODO: move zed::open_local_file() APIs to this crate, and
3303 // re-implement the "initial_contents" behavior
3304 corresponding_workspace
3305 .update(cx, |_, window, cx| {
3306 cx.spawn_in(window, async move |workspace, cx| {
3307 if let Some(create_task) = create_task {
3308 create_task.await.ok()?;
3309 };
3310
3311 workspace
3312 .update_in(cx, |workspace, window, cx| {
3313 workspace.open_path(
3314 (worktree_id, settings_path.clone()),
3315 None,
3316 true,
3317 window,
3318 cx,
3319 )
3320 })
3321 .ok()?
3322 .await
3323 .log_err()?;
3324
3325 workspace
3326 .update_in(cx, |_, window, cx| {
3327 window.activate_window();
3328 cx.notify();
3329 })
3330 .ok();
3331
3332 Some(())
3333 })
3334 .detach();
3335 })
3336 .ok();
3337
3338 window.remove_window();
3339 }
3340 SettingsUiFile::Server(_) => {
3341 // Server files are not editable
3342 return;
3343 }
3344 };
3345 }
3346
3347 fn current_page_index(&self) -> usize {
3348 self.page_index_from_navbar_index(self.navbar_entry)
3349 }
3350
3351 fn current_page(&self) -> &SettingsPage {
3352 &self.pages[self.current_page_index()]
3353 }
3354
3355 fn page_index_from_navbar_index(&self, index: usize) -> usize {
3356 if self.navbar_entries.is_empty() {
3357 return 0;
3358 }
3359
3360 self.navbar_entries[index].page_index
3361 }
3362
3363 fn is_navbar_entry_selected(&self, ix: usize) -> bool {
3364 ix == self.navbar_entry
3365 }
3366
3367 fn push_sub_page(
3368 &mut self,
3369 sub_page_link: SubPageLink,
3370 section_header: &'static str,
3371 cx: &mut Context<SettingsWindow>,
3372 ) {
3373 sub_page_stack_mut().push(SubPage {
3374 link: sub_page_link,
3375 section_header,
3376 });
3377 cx.notify();
3378 }
3379
3380 fn pop_sub_page(&mut self, cx: &mut Context<SettingsWindow>) {
3381 sub_page_stack_mut().pop();
3382 cx.notify();
3383 }
3384
3385 fn focus_file_at_index(&mut self, index: usize, window: &mut Window) {
3386 if let Some((_, handle)) = self.files.get(index) {
3387 handle.focus(window);
3388 }
3389 }
3390
3391 fn focused_file_index(&self, window: &Window, cx: &Context<Self>) -> usize {
3392 if self.files_focus_handle.contains_focused(window, cx)
3393 && let Some(index) = self
3394 .files
3395 .iter()
3396 .position(|(_, handle)| handle.is_focused(window))
3397 {
3398 return index;
3399 }
3400 if let Some(current_file_index) = self
3401 .files
3402 .iter()
3403 .position(|(file, _)| file == &self.current_file)
3404 {
3405 return current_file_index;
3406 }
3407 0
3408 }
3409
3410 fn focus_handle_for_content_element(
3411 &self,
3412 actual_item_index: usize,
3413 cx: &Context<Self>,
3414 ) -> FocusHandle {
3415 let page_index = self.current_page_index();
3416 self.content_handles[page_index][actual_item_index].focus_handle(cx)
3417 }
3418
3419 fn focused_nav_entry(&self, window: &Window, cx: &App) -> Option<usize> {
3420 if !self
3421 .navbar_focus_handle
3422 .focus_handle(cx)
3423 .contains_focused(window, cx)
3424 {
3425 return None;
3426 }
3427 for (index, entry) in self.navbar_entries.iter().enumerate() {
3428 if entry.focus_handle.is_focused(window) {
3429 return Some(index);
3430 }
3431 }
3432 None
3433 }
3434
3435 fn root_entry_containing(&self, nav_entry_index: usize) -> usize {
3436 let mut index = Some(nav_entry_index);
3437 while let Some(prev_index) = index
3438 && !self.navbar_entries[prev_index].is_root
3439 {
3440 index = prev_index.checked_sub(1);
3441 }
3442 return index.expect("No root entry found");
3443 }
3444}
3445
3446impl Render for SettingsWindow {
3447 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
3448 let ui_font = theme::setup_ui_font(window, cx);
3449
3450 client_side_decorations(
3451 v_flex()
3452 .text_color(cx.theme().colors().text)
3453 .size_full()
3454 .children(self.title_bar.clone())
3455 .child(
3456 div()
3457 .id("settings-window")
3458 .key_context("SettingsWindow")
3459 .track_focus(&self.focus_handle)
3460 .on_action(cx.listener(|this, _: &OpenCurrentFile, window, cx| {
3461 this.open_current_settings_file(window, cx);
3462 }))
3463 .on_action(|_: &Minimize, window, _cx| {
3464 window.minimize_window();
3465 })
3466 .on_action(cx.listener(|this, _: &search::FocusSearch, window, cx| {
3467 this.search_bar.focus_handle(cx).focus(window);
3468 }))
3469 .on_action(cx.listener(|this, _: &ToggleFocusNav, window, cx| {
3470 if this
3471 .navbar_focus_handle
3472 .focus_handle(cx)
3473 .contains_focused(window, cx)
3474 {
3475 this.open_and_scroll_to_navbar_entry(
3476 this.navbar_entry,
3477 None,
3478 true,
3479 window,
3480 cx,
3481 );
3482 } else {
3483 this.focus_and_scroll_to_nav_entry(this.navbar_entry, window, cx);
3484 }
3485 }))
3486 .on_action(cx.listener(
3487 |this, FocusFile(file_index): &FocusFile, window, _| {
3488 this.focus_file_at_index(*file_index as usize, window);
3489 },
3490 ))
3491 .on_action(cx.listener(|this, _: &FocusNextFile, window, cx| {
3492 let next_index = usize::min(
3493 this.focused_file_index(window, cx) + 1,
3494 this.files.len().saturating_sub(1),
3495 );
3496 this.focus_file_at_index(next_index, window);
3497 }))
3498 .on_action(cx.listener(|this, _: &FocusPreviousFile, window, cx| {
3499 let prev_index = this.focused_file_index(window, cx).saturating_sub(1);
3500 this.focus_file_at_index(prev_index, window);
3501 }))
3502 .on_action(cx.listener(|this, _: &menu::SelectNext, window, cx| {
3503 if this
3504 .search_bar
3505 .focus_handle(cx)
3506 .contains_focused(window, cx)
3507 {
3508 this.focus_and_scroll_to_first_visible_nav_entry(window, cx);
3509 } else {
3510 window.focus_next();
3511 }
3512 }))
3513 .on_action(|_: &menu::SelectPrevious, window, _| {
3514 window.focus_prev();
3515 })
3516 .flex()
3517 .flex_row()
3518 .flex_1()
3519 .min_h_0()
3520 .font(ui_font)
3521 .bg(cx.theme().colors().background)
3522 .text_color(cx.theme().colors().text)
3523 .when(!cfg!(target_os = "macos"), |this| {
3524 this.border_t_1().border_color(cx.theme().colors().border)
3525 })
3526 .child(self.render_nav(window, cx))
3527 .child(self.render_page(window, cx)),
3528 ),
3529 window,
3530 cx,
3531 )
3532 }
3533}
3534
3535fn all_projects(cx: &App) -> impl Iterator<Item = Entity<project::Project>> {
3536 workspace::AppState::global(cx)
3537 .upgrade()
3538 .map(|app_state| {
3539 app_state
3540 .workspace_store
3541 .read(cx)
3542 .workspaces()
3543 .iter()
3544 .filter_map(|workspace| Some(workspace.read(cx).ok()?.project().clone()))
3545 })
3546 .into_iter()
3547 .flatten()
3548}
3549
3550fn update_settings_file(
3551 file: SettingsUiFile,
3552 file_name: Option<&'static str>,
3553 cx: &mut App,
3554 update: impl 'static + Send + FnOnce(&mut SettingsContent, &App),
3555) -> Result<()> {
3556 telemetry::event!("Settings Change", setting = file_name, type = file.setting_type());
3557
3558 match file {
3559 SettingsUiFile::Project((worktree_id, rel_path)) => {
3560 let rel_path = rel_path.join(paths::local_settings_file_relative_path());
3561 let Some((worktree, project)) = all_projects(cx).find_map(|project| {
3562 project
3563 .read(cx)
3564 .worktree_for_id(worktree_id, cx)
3565 .zip(Some(project))
3566 }) else {
3567 anyhow::bail!("Could not find project with worktree id: {}", worktree_id);
3568 };
3569
3570 project.update(cx, |project, cx| {
3571 let task = if project.contains_local_settings_file(worktree_id, &rel_path, cx) {
3572 None
3573 } else {
3574 Some(worktree.update(cx, |worktree, cx| {
3575 worktree.create_entry(rel_path.clone(), false, None, cx)
3576 }))
3577 };
3578
3579 cx.spawn(async move |project, cx| {
3580 if let Some(task) = task
3581 && task.await.is_err()
3582 {
3583 return;
3584 };
3585
3586 project
3587 .update(cx, |project, cx| {
3588 project.update_local_settings_file(worktree_id, rel_path, cx, update);
3589 })
3590 .ok();
3591 })
3592 .detach();
3593 });
3594
3595 return Ok(());
3596 }
3597 SettingsUiFile::User => {
3598 // todo(settings_ui) error?
3599 SettingsStore::global(cx).update_settings_file(<dyn fs::Fs>::global(cx), update);
3600 Ok(())
3601 }
3602 SettingsUiFile::Server(_) => unimplemented!(),
3603 }
3604}
3605
3606fn render_text_field<T: From<String> + Into<String> + AsRef<str> + Clone>(
3607 field: SettingField<T>,
3608 file: SettingsUiFile,
3609 metadata: Option<&SettingsFieldMetadata>,
3610 _window: &mut Window,
3611 cx: &mut App,
3612) -> AnyElement {
3613 let (_, initial_text) =
3614 SettingsStore::global(cx).get_value_from_file(file.to_settings(), field.pick);
3615 let initial_text = initial_text.filter(|s| !s.as_ref().is_empty());
3616
3617 SettingsInputField::new()
3618 .tab_index(0)
3619 .when_some(initial_text, |editor, text| {
3620 editor.with_initial_text(text.as_ref().to_string())
3621 })
3622 .when_some(
3623 metadata.and_then(|metadata| metadata.placeholder),
3624 |editor, placeholder| editor.with_placeholder(placeholder),
3625 )
3626 .on_confirm({
3627 move |new_text, cx| {
3628 update_settings_file(file.clone(), field.json_path, cx, move |settings, _cx| {
3629 (field.write)(settings, new_text.map(Into::into));
3630 })
3631 .log_err(); // todo(settings_ui) don't log err
3632 }
3633 })
3634 .into_any_element()
3635}
3636
3637fn render_toggle_button<B: Into<bool> + From<bool> + Copy>(
3638 field: SettingField<B>,
3639 file: SettingsUiFile,
3640 _metadata: Option<&SettingsFieldMetadata>,
3641 _window: &mut Window,
3642 cx: &mut App,
3643) -> AnyElement {
3644 let (_, value) = SettingsStore::global(cx).get_value_from_file(file.to_settings(), field.pick);
3645
3646 let toggle_state = if value.copied().map_or(false, Into::into) {
3647 ToggleState::Selected
3648 } else {
3649 ToggleState::Unselected
3650 };
3651
3652 Switch::new("toggle_button", toggle_state)
3653 .tab_index(0_isize)
3654 .on_click({
3655 move |state, _window, cx| {
3656 telemetry::event!("Settings Change", setting = field.json_path, type = file.setting_type());
3657
3658 let state = *state == ui::ToggleState::Selected;
3659 update_settings_file(file.clone(), field.json_path, cx, move |settings, _cx| {
3660 (field.write)(settings, Some(state.into()));
3661 })
3662 .log_err(); // todo(settings_ui) don't log err
3663 }
3664 })
3665 .into_any_element()
3666}
3667
3668fn render_number_field<T: NumberFieldType + Send + Sync>(
3669 field: SettingField<T>,
3670 file: SettingsUiFile,
3671 _metadata: Option<&SettingsFieldMetadata>,
3672 window: &mut Window,
3673 cx: &mut App,
3674) -> AnyElement {
3675 let (_, value) = SettingsStore::global(cx).get_value_from_file(file.to_settings(), field.pick);
3676 let value = value.copied().unwrap_or_else(T::min_value);
3677 NumberField::new("numeric_stepper", value, window, cx)
3678 .on_change({
3679 move |value, _window, cx| {
3680 let value = *value;
3681 update_settings_file(file.clone(), field.json_path, cx, move |settings, _cx| {
3682 (field.write)(settings, Some(value));
3683 })
3684 .log_err(); // todo(settings_ui) don't log err
3685 }
3686 })
3687 .into_any_element()
3688}
3689
3690fn render_dropdown<T>(
3691 field: SettingField<T>,
3692 file: SettingsUiFile,
3693 metadata: Option<&SettingsFieldMetadata>,
3694 _window: &mut Window,
3695 cx: &mut App,
3696) -> AnyElement
3697where
3698 T: strum::VariantArray + strum::VariantNames + Copy + PartialEq + Send + Sync + 'static,
3699{
3700 let variants = || -> &'static [T] { <T as strum::VariantArray>::VARIANTS };
3701 let labels = || -> &'static [&'static str] { <T as strum::VariantNames>::VARIANTS };
3702 let should_do_titlecase = metadata
3703 .and_then(|metadata| metadata.should_do_titlecase)
3704 .unwrap_or(true);
3705
3706 let (_, current_value) =
3707 SettingsStore::global(cx).get_value_from_file(file.to_settings(), field.pick);
3708 let current_value = current_value.copied().unwrap_or(variants()[0]);
3709
3710 EnumVariantDropdown::new("dropdown", current_value, variants(), labels(), {
3711 move |value, cx| {
3712 if value == current_value {
3713 return;
3714 }
3715 update_settings_file(file.clone(), field.json_path, cx, move |settings, _cx| {
3716 (field.write)(settings, Some(value));
3717 })
3718 .log_err(); // todo(settings_ui) don't log err
3719 }
3720 })
3721 .tab_index(0)
3722 .title_case(should_do_titlecase)
3723 .into_any_element()
3724}
3725
3726fn render_picker_trigger_button(id: SharedString, label: SharedString) -> Button {
3727 Button::new(id, label)
3728 .tab_index(0_isize)
3729 .style(ButtonStyle::Outlined)
3730 .size(ButtonSize::Medium)
3731 .icon(IconName::ChevronUpDown)
3732 .icon_color(Color::Muted)
3733 .icon_size(IconSize::Small)
3734 .icon_position(IconPosition::End)
3735}
3736
3737fn render_font_picker(
3738 field: SettingField<settings::FontFamilyName>,
3739 file: SettingsUiFile,
3740 _metadata: Option<&SettingsFieldMetadata>,
3741 _window: &mut Window,
3742 cx: &mut App,
3743) -> AnyElement {
3744 let current_value = SettingsStore::global(cx)
3745 .get_value_from_file(file.to_settings(), field.pick)
3746 .1
3747 .cloned()
3748 .unwrap_or_else(|| SharedString::default().into());
3749
3750 PopoverMenu::new("font-picker")
3751 .trigger(render_picker_trigger_button(
3752 "font_family_picker_trigger".into(),
3753 current_value.clone().into(),
3754 ))
3755 .menu(move |window, cx| {
3756 let file = file.clone();
3757 let current_value = current_value.clone();
3758
3759 Some(cx.new(move |cx| {
3760 font_picker(
3761 current_value.clone().into(),
3762 move |font_name, cx| {
3763 update_settings_file(
3764 file.clone(),
3765 field.json_path,
3766 cx,
3767 move |settings, _cx| {
3768 (field.write)(settings, Some(font_name.into()));
3769 },
3770 )
3771 .log_err(); // todo(settings_ui) don't log err
3772 },
3773 window,
3774 cx,
3775 )
3776 }))
3777 })
3778 .anchor(gpui::Corner::TopLeft)
3779 .offset(gpui::Point {
3780 x: px(0.0),
3781 y: px(2.0),
3782 })
3783 .with_handle(ui::PopoverMenuHandle::default())
3784 .into_any_element()
3785}
3786
3787fn render_theme_picker(
3788 field: SettingField<settings::ThemeName>,
3789 file: SettingsUiFile,
3790 _metadata: Option<&SettingsFieldMetadata>,
3791 _window: &mut Window,
3792 cx: &mut App,
3793) -> AnyElement {
3794 let (_, value) = SettingsStore::global(cx).get_value_from_file(file.to_settings(), field.pick);
3795 let current_value = value
3796 .cloned()
3797 .map(|theme_name| theme_name.0.into())
3798 .unwrap_or_else(|| cx.theme().name.clone());
3799
3800 PopoverMenu::new("theme-picker")
3801 .trigger(render_picker_trigger_button(
3802 "theme_picker_trigger".into(),
3803 current_value.clone(),
3804 ))
3805 .menu(move |window, cx| {
3806 Some(cx.new(|cx| {
3807 let file = file.clone();
3808 let current_value = current_value.clone();
3809 theme_picker(
3810 current_value,
3811 move |theme_name, cx| {
3812 update_settings_file(
3813 file.clone(),
3814 field.json_path,
3815 cx,
3816 move |settings, _cx| {
3817 (field.write)(
3818 settings,
3819 Some(settings::ThemeName(theme_name.into())),
3820 );
3821 },
3822 )
3823 .log_err(); // todo(settings_ui) don't log err
3824 },
3825 window,
3826 cx,
3827 )
3828 }))
3829 })
3830 .anchor(gpui::Corner::TopLeft)
3831 .offset(gpui::Point {
3832 x: px(0.0),
3833 y: px(2.0),
3834 })
3835 .with_handle(ui::PopoverMenuHandle::default())
3836 .into_any_element()
3837}
3838
3839fn render_icon_theme_picker(
3840 field: SettingField<settings::IconThemeName>,
3841 file: SettingsUiFile,
3842 _metadata: Option<&SettingsFieldMetadata>,
3843 _window: &mut Window,
3844 cx: &mut App,
3845) -> AnyElement {
3846 let (_, value) = SettingsStore::global(cx).get_value_from_file(file.to_settings(), field.pick);
3847 let current_value = value
3848 .cloned()
3849 .map(|theme_name| theme_name.0.into())
3850 .unwrap_or_else(|| cx.theme().name.clone());
3851
3852 PopoverMenu::new("icon-theme-picker")
3853 .trigger(render_picker_trigger_button(
3854 "icon_theme_picker_trigger".into(),
3855 current_value.clone(),
3856 ))
3857 .menu(move |window, cx| {
3858 Some(cx.new(|cx| {
3859 let file = file.clone();
3860 let current_value = current_value.clone();
3861 icon_theme_picker(
3862 current_value,
3863 move |theme_name, cx| {
3864 update_settings_file(
3865 file.clone(),
3866 field.json_path,
3867 cx,
3868 move |settings, _cx| {
3869 (field.write)(
3870 settings,
3871 Some(settings::IconThemeName(theme_name.into())),
3872 );
3873 },
3874 )
3875 .log_err(); // todo(settings_ui) don't log err
3876 },
3877 window,
3878 cx,
3879 )
3880 }))
3881 })
3882 .anchor(gpui::Corner::TopLeft)
3883 .offset(gpui::Point {
3884 x: px(0.0),
3885 y: px(2.0),
3886 })
3887 .with_handle(ui::PopoverMenuHandle::default())
3888 .into_any_element()
3889}
3890
3891#[cfg(test)]
3892pub mod test {
3893
3894 use super::*;
3895
3896 impl SettingsWindow {
3897 fn navbar_entry(&self) -> usize {
3898 self.navbar_entry
3899 }
3900 }
3901
3902 impl PartialEq for NavBarEntry {
3903 fn eq(&self, other: &Self) -> bool {
3904 self.title == other.title
3905 && self.is_root == other.is_root
3906 && self.expanded == other.expanded
3907 && self.page_index == other.page_index
3908 && self.item_index == other.item_index
3909 // ignoring focus_handle
3910 }
3911 }
3912
3913 pub fn register_settings(cx: &mut App) {
3914 settings::init(cx);
3915 theme::init(theme::LoadThemes::JustBase, cx);
3916 editor::init(cx);
3917 menu::init();
3918 }
3919
3920 fn parse(input: &'static str, window: &mut Window, cx: &mut App) -> SettingsWindow {
3921 let mut pages: Vec<SettingsPage> = Vec::new();
3922 let mut expanded_pages = Vec::new();
3923 let mut selected_idx = None;
3924 let mut index = 0;
3925 let mut in_expanded_section = false;
3926
3927 for mut line in input
3928 .lines()
3929 .map(|line| line.trim())
3930 .filter(|line| !line.is_empty())
3931 {
3932 if let Some(pre) = line.strip_suffix('*') {
3933 assert!(selected_idx.is_none(), "Only one selected entry allowed");
3934 selected_idx = Some(index);
3935 line = pre;
3936 }
3937 let (kind, title) = line.split_once(" ").unwrap();
3938 assert_eq!(kind.len(), 1);
3939 let kind = kind.chars().next().unwrap();
3940 if kind == 'v' {
3941 let page_idx = pages.len();
3942 expanded_pages.push(page_idx);
3943 pages.push(SettingsPage {
3944 title,
3945 items: vec![],
3946 });
3947 index += 1;
3948 in_expanded_section = true;
3949 } else if kind == '>' {
3950 pages.push(SettingsPage {
3951 title,
3952 items: vec![],
3953 });
3954 index += 1;
3955 in_expanded_section = false;
3956 } else if kind == '-' {
3957 pages
3958 .last_mut()
3959 .unwrap()
3960 .items
3961 .push(SettingsPageItem::SectionHeader(title));
3962 if selected_idx == Some(index) && !in_expanded_section {
3963 panic!("Items in unexpanded sections cannot be selected");
3964 }
3965 index += 1;
3966 } else {
3967 panic!(
3968 "Entries must start with one of 'v', '>', or '-'\n line: {}",
3969 line
3970 );
3971 }
3972 }
3973
3974 let mut settings_window = SettingsWindow {
3975 title_bar: None,
3976 original_window: None,
3977 worktree_root_dirs: HashMap::default(),
3978 files: Vec::default(),
3979 current_file: crate::SettingsUiFile::User,
3980 pages,
3981 search_bar: cx.new(|cx| Editor::single_line(window, cx)),
3982 navbar_entry: selected_idx.expect("Must have a selected navbar entry"),
3983 navbar_entries: Vec::default(),
3984 navbar_scroll_handle: UniformListScrollHandle::default(),
3985 navbar_focus_subscriptions: vec![],
3986 filter_table: vec![],
3987 has_query: false,
3988 content_handles: vec![],
3989 search_task: None,
3990 sub_page_scroll_handle: ScrollHandle::new(),
3991 focus_handle: cx.focus_handle(),
3992 navbar_focus_handle: NonFocusableHandle::new(
3993 NAVBAR_CONTAINER_TAB_INDEX,
3994 false,
3995 window,
3996 cx,
3997 ),
3998 content_focus_handle: NonFocusableHandle::new(
3999 CONTENT_CONTAINER_TAB_INDEX,
4000 false,
4001 window,
4002 cx,
4003 ),
4004 files_focus_handle: cx.focus_handle(),
4005 search_index: None,
4006 list_state: ListState::new(0, gpui::ListAlignment::Top, px(0.0)),
4007 shown_errors: HashSet::default(),
4008 };
4009
4010 settings_window.build_filter_table();
4011 settings_window.build_navbar(cx);
4012 for expanded_page_index in expanded_pages {
4013 for entry in &mut settings_window.navbar_entries {
4014 if entry.page_index == expanded_page_index && entry.is_root {
4015 entry.expanded = true;
4016 }
4017 }
4018 }
4019 settings_window
4020 }
4021
4022 #[track_caller]
4023 fn check_navbar_toggle(
4024 before: &'static str,
4025 toggle_page: &'static str,
4026 after: &'static str,
4027 window: &mut Window,
4028 cx: &mut App,
4029 ) {
4030 let mut settings_window = parse(before, window, cx);
4031 let toggle_page_idx = settings_window
4032 .pages
4033 .iter()
4034 .position(|page| page.title == toggle_page)
4035 .expect("page not found");
4036 let toggle_idx = settings_window
4037 .navbar_entries
4038 .iter()
4039 .position(|entry| entry.page_index == toggle_page_idx)
4040 .expect("page not found");
4041 settings_window.toggle_navbar_entry(toggle_idx);
4042
4043 let expected_settings_window = parse(after, window, cx);
4044
4045 pretty_assertions::assert_eq!(
4046 settings_window
4047 .visible_navbar_entries()
4048 .map(|(_, entry)| entry)
4049 .collect::<Vec<_>>(),
4050 expected_settings_window
4051 .visible_navbar_entries()
4052 .map(|(_, entry)| entry)
4053 .collect::<Vec<_>>(),
4054 );
4055 pretty_assertions::assert_eq!(
4056 settings_window.navbar_entries[settings_window.navbar_entry()],
4057 expected_settings_window.navbar_entries[expected_settings_window.navbar_entry()],
4058 );
4059 }
4060
4061 macro_rules! check_navbar_toggle {
4062 ($name:ident, before: $before:expr, toggle_page: $toggle_page:expr, after: $after:expr) => {
4063 #[gpui::test]
4064 fn $name(cx: &mut gpui::TestAppContext) {
4065 let window = cx.add_empty_window();
4066 window.update(|window, cx| {
4067 register_settings(cx);
4068 check_navbar_toggle($before, $toggle_page, $after, window, cx);
4069 });
4070 }
4071 };
4072 }
4073
4074 check_navbar_toggle!(
4075 navbar_basic_open,
4076 before: r"
4077 v General
4078 - General
4079 - Privacy*
4080 v Project
4081 - Project Settings
4082 ",
4083 toggle_page: "General",
4084 after: r"
4085 > General*
4086 v Project
4087 - Project Settings
4088 "
4089 );
4090
4091 check_navbar_toggle!(
4092 navbar_basic_close,
4093 before: r"
4094 > General*
4095 - General
4096 - Privacy
4097 v Project
4098 - Project Settings
4099 ",
4100 toggle_page: "General",
4101 after: r"
4102 v General*
4103 - General
4104 - Privacy
4105 v Project
4106 - Project Settings
4107 "
4108 );
4109
4110 check_navbar_toggle!(
4111 navbar_basic_second_root_entry_close,
4112 before: r"
4113 > General
4114 - General
4115 - Privacy
4116 v Project
4117 - Project Settings*
4118 ",
4119 toggle_page: "Project",
4120 after: r"
4121 > General
4122 > Project*
4123 "
4124 );
4125
4126 check_navbar_toggle!(
4127 navbar_toggle_subroot,
4128 before: r"
4129 v General Page
4130 - General
4131 - Privacy
4132 v Project
4133 - Worktree Settings Content*
4134 v AI
4135 - General
4136 > Appearance & Behavior
4137 ",
4138 toggle_page: "Project",
4139 after: r"
4140 v General Page
4141 - General
4142 - Privacy
4143 > Project*
4144 v AI
4145 - General
4146 > Appearance & Behavior
4147 "
4148 );
4149
4150 check_navbar_toggle!(
4151 navbar_toggle_close_propagates_selected_index,
4152 before: r"
4153 v General Page
4154 - General
4155 - Privacy
4156 v Project
4157 - Worktree Settings Content
4158 v AI
4159 - General*
4160 > Appearance & Behavior
4161 ",
4162 toggle_page: "General Page",
4163 after: r"
4164 > General Page*
4165 v Project
4166 - Worktree Settings Content
4167 v AI
4168 - General
4169 > Appearance & Behavior
4170 "
4171 );
4172
4173 check_navbar_toggle!(
4174 navbar_toggle_expand_propagates_selected_index,
4175 before: r"
4176 > General Page
4177 - General
4178 - Privacy
4179 v Project
4180 - Worktree Settings Content
4181 v AI
4182 - General*
4183 > Appearance & Behavior
4184 ",
4185 toggle_page: "General Page",
4186 after: r"
4187 v General Page*
4188 - General
4189 - Privacy
4190 v Project
4191 - Worktree Settings Content
4192 v AI
4193 - General
4194 > Appearance & Behavior
4195 "
4196 );
4197}