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