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