Skip to repository content6938 lines · 261.6 KB · rust
tenant.openagents/omega
No repository description is available.
OpenAgents Git authority 2026-07-28T04:44:05.519Z Public web read
NIP-34 coordinate
30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omegaMaintainersHidden in public view
References2 branches · 1 tag
Read-only clone
git clone https://openagents.com/git/tenant.openagents/omega.gitBrowse files
settings_ui.rs
1mod components;
2mod page_data;
3pub mod pages;
4
5use agent_skills::SkillIndex;
6use anyhow::{Context as _, Result};
7use cloud_api_types::OrganizationConfiguration;
8use editor::{Editor, EditorEvent};
9use futures::{StreamExt, channel::mpsc};
10use fuzzy::StringMatchCandidate;
11use gpui::{
12 Action, App, AsyncApp, ClipboardItem, DEFAULT_ADDITIONAL_WINDOW_SIZE, Div, Entity, FocusHandle,
13 Focusable, Global, KeyContext, ListState, ReadGlobal as _, Role, ScrollHandle, Stateful,
14 Subscription, Task, TitlebarOptions, UniformListScrollHandle, WeakEntity, Window, WindowBounds,
15 WindowHandle, WindowOptions, actions, div, list, point, prelude::*, px, uniform_list,
16};
17
18use language::Buffer;
19use platform_title_bar::PlatformTitleBar;
20use project::{Project, ProjectPath, Worktree, WorktreeId};
21use release_channel::ReleaseChannel;
22use schemars::JsonSchema;
23use serde::Deserialize;
24use settings::{
25 IntoGpui, Settings, SettingsContent, SettingsStore, initial_project_settings_content,
26};
27use std::{
28 any::{Any, TypeId, type_name},
29 cell::RefCell,
30 collections::{HashMap, HashSet},
31 num::{NonZero, NonZeroU32},
32 ops::Range,
33 path::PathBuf,
34 rc::Rc,
35 sync::{Arc, LazyLock, RwLock},
36 time::Duration,
37};
38use theme_settings::ThemeSettings;
39use ui::{
40 Banner, ContextMenu, Divider, DropdownMenu, DropdownStyle, IconButtonShape, KeyBinding,
41 KeybindingHint, PopoverMenu, Scrollbars, Switch, Tooltip, TreeViewItem, WithScrollbar,
42 prelude::*,
43};
44
45use util::{ResultExt as _, paths::PathStyle, rel_path::RelPath};
46use workspace::{
47 AppState, MultiWorkspace, OpenOptions, OpenVisible, Workspace, WorkspaceSettings,
48 client_side_decorations,
49};
50use zed_actions::{
51 AGENT_SKILLS_SETTINGS_PATH, OpenProjectSettings, OpenSettings, OpenSettingsAt,
52 OpenSettingsAtTarget, OpenSettingsPage,
53};
54
55use crate::components::{
56 EnumVariantDropdown, NumberField, NumberFieldMode, NumberFieldType, SettingsInputField,
57 SettingsSectionHeader, font_picker, icon_theme_picker, render_ollama_model_picker,
58 text_field_a11y_state, theme_picker,
59};
60use crate::pages::{
61 CustomAgentForm, LlmProviderForm, McpServerForm, render_input_audio_device_dropdown,
62 render_output_audio_device_dropdown,
63};
64
65const NAVBAR_CONTAINER_TAB_INDEX: isize = 0;
66const NAVBAR_GROUP_TAB_INDEX: isize = 1;
67
68const HEADER_CONTAINER_TAB_INDEX: isize = 2;
69const HEADER_GROUP_TAB_INDEX: isize = 3;
70
71const CONTENT_CONTAINER_TAB_INDEX: isize = 4;
72const CONTENT_GROUP_TAB_INDEX: isize = 5;
73
74const SIDEBAR_WIDTH: Pixels = px(226.);
75const CONTENT_MIN_WIDTH: Pixels = px(400.);
76
77actions!(
78 settings_editor,
79 [
80 /// Minimizes the settings UI window.
81 Minimize,
82 /// Toggles focus between the navbar and the main content.
83 ToggleFocusNav,
84 /// Expands the navigation entry.
85 ExpandNavEntry,
86 /// Collapses the navigation entry.
87 CollapseNavEntry,
88 /// Focuses the next file in the file list.
89 FocusNextFile,
90 /// Focuses the previous file in the file list.
91 FocusPreviousFile,
92 /// Opens an editor for the current file
93 OpenCurrentFile,
94 /// Focuses the previous root navigation entry.
95 FocusPreviousRootNavEntry,
96 /// Focuses the next root navigation entry.
97 FocusNextRootNavEntry,
98 /// Focuses the first navigation entry.
99 FocusFirstNavEntry,
100 /// Focuses the last navigation entry.
101 FocusLastNavEntry,
102 /// Focuses and opens the next navigation entry without moving focus to content.
103 FocusNextNavEntry,
104 /// Focuses and opens the previous navigation entry without moving focus to content.
105 FocusPreviousNavEntry
106 ]
107);
108
109#[derive(Action, PartialEq, Eq, Clone, Copy, Debug, JsonSchema, Deserialize)]
110#[action(namespace = settings_editor)]
111struct FocusFile(pub u32);
112
113struct SettingField<T: 'static> {
114 pick: fn(&SettingsContent) -> Option<&T>,
115 write: fn(&mut SettingsContent, Option<T>, &App),
116 /// Tells us whether the setting is overridden by the currently selected
117 /// organization's settings. Takes the organization configuration and the
118 /// resolved settings value, and returns `Some(...)` if the organization
119 /// overrides the setting, otherwise `None`.
120 organization_override: Option<fn(&OrganizationConfiguration) -> Option<&T>>,
121
122 /// A json-path-like string that gives a unique-ish string that identifies
123 /// where in the JSON the setting is defined.
124 ///
125 /// The syntax is `jq`-like, but modified slightly to be URL-safe (and
126 /// without the leading dot), e.g. `foo.bar`.
127 ///
128 /// They are URL-safe (this is important since links are the main use-case
129 /// for these paths).
130 ///
131 /// There are a couple of special cases:
132 /// - discrimminants are represented with a trailing `$`, for example
133 /// `terminal.working_directory$`. This is to distinguish the discrimminant
134 /// setting (i.e. the setting that changes whether the value is a string or
135 /// an object) from the setting in the case that it is a string.
136 /// - language-specific settings begin `languages.$(language)`. Links
137 /// targeting these settings should take the form `languages/Rust/...`, for
138 /// example, but are not currently supported.
139 json_path: Option<&'static str>,
140}
141
142impl<T: 'static> Clone for SettingField<T> {
143 fn clone(&self) -> Self {
144 *self
145 }
146}
147
148// manual impl because derive puts a Copy bound on T, which is inaccurate in our case
149impl<T: 'static> Copy for SettingField<T> {}
150
151/// Helper for unimplemented settings, used in combination with `SettingField::unimplemented`
152/// to keep the setting around in the UI with valid pick and write implementations, but don't actually try to render it.
153/// TODO(settings_ui): In non-dev builds (`#[cfg(not(debug_assertions))]`) make this render as edit-in-json
154#[derive(Clone, Copy)]
155struct UnimplementedSettingField;
156
157impl PartialEq for UnimplementedSettingField {
158 fn eq(&self, _other: &Self) -> bool {
159 true
160 }
161}
162
163impl<T: 'static> SettingField<T> {
164 /// Helper for settings with types that are not yet implemented.
165 #[allow(unused)]
166 fn unimplemented(self) -> SettingField<UnimplementedSettingField> {
167 SettingField {
168 pick: |_| Some(&UnimplementedSettingField),
169 write: |_, _, _| unreachable!(),
170 organization_override: None,
171 json_path: self.json_path,
172 }
173 }
174}
175
176trait AnySettingField {
177 fn as_any(&self) -> &dyn Any;
178 fn type_name(&self) -> &'static str;
179 fn type_id(&self) -> TypeId;
180 // 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)
181 fn file_set_in(&self, file: SettingsUiFile, cx: &App) -> (settings::SettingsFile, bool);
182 fn reset_to_default_fn(
183 &self,
184 current_file: &SettingsUiFile,
185 file_set_in: &settings::SettingsFile,
186 cx: &App,
187 ) -> Option<Box<dyn Fn(&mut Window, &mut App)>>;
188
189 fn json_path(&self) -> Option<&'static str>;
190
191 fn is_overridden_by_organization(&self, cx: &App) -> bool;
192}
193
194impl<T: PartialEq + Clone + Send + Sync + 'static> AnySettingField for SettingField<T> {
195 fn as_any(&self) -> &dyn Any {
196 self
197 }
198
199 fn type_name(&self) -> &'static str {
200 type_name::<T>()
201 }
202
203 fn type_id(&self) -> TypeId {
204 TypeId::of::<T>()
205 }
206
207 fn file_set_in(&self, file: SettingsUiFile, cx: &App) -> (settings::SettingsFile, bool) {
208 let (file, value) = cx
209 .global::<SettingsStore>()
210 .get_value_from_file(file.to_settings(), self.pick);
211 return (file, value.is_some());
212 }
213
214 fn reset_to_default_fn(
215 &self,
216 current_file: &SettingsUiFile,
217 file_set_in: &settings::SettingsFile,
218 cx: &App,
219 ) -> Option<Box<dyn Fn(&mut Window, &mut App)>> {
220 if file_set_in == &settings::SettingsFile::Default {
221 return None;
222 }
223 if file_set_in != ¤t_file.to_settings() {
224 return None;
225 }
226 let this = *self;
227 let store = SettingsStore::global(cx);
228 let default_value = (this.pick)(store.raw_default_settings());
229 let is_default = store
230 .get_content_for_file(file_set_in.clone())
231 .map_or(None, this.pick)
232 == default_value;
233 if is_default {
234 return None;
235 }
236 let current_file = current_file.clone();
237
238 return Some(Box::new(move |window, cx| {
239 let store = SettingsStore::global(cx);
240 let default_value = (this.pick)(store.raw_default_settings());
241 let is_set_somewhere_other_than_default = store
242 .get_value_up_to_file(current_file.to_settings(), this.pick)
243 .0
244 != settings::SettingsFile::Default;
245 let value_to_set = if is_set_somewhere_other_than_default {
246 default_value.cloned()
247 } else {
248 None
249 };
250 update_settings_file(
251 current_file.clone(),
252 None,
253 window,
254 cx,
255 move |settings, app| {
256 (this.write)(settings, value_to_set, app);
257 },
258 )
259 // todo(settings_ui): Don't log err
260 .log_err();
261 }));
262 }
263
264 fn json_path(&self) -> Option<&'static str> {
265 self.json_path
266 }
267
268 fn is_overridden_by_organization(&self, cx: &App) -> bool {
269 let Some(org_override) = self.organization_override else {
270 return false;
271 };
272
273 let user_store = AppState::global(cx).user_store.read(cx);
274 let Some(org_config) = user_store.current_organization_configuration() else {
275 return false;
276 };
277
278 (org_override)(&org_config).is_some()
279 }
280}
281
282#[derive(Default, Clone)]
283struct SettingFieldRenderer {
284 renderers: Rc<
285 RefCell<
286 HashMap<
287 TypeId,
288 Box<
289 dyn Fn(
290 &SettingsWindow,
291 &SettingItem,
292 SettingsUiFile,
293 Option<&SettingsFieldMetadata>,
294 bool,
295 &mut Window,
296 &mut Context<SettingsWindow>,
297 ) -> Stateful<Div>,
298 >,
299 >,
300 >,
301 >,
302}
303
304impl Global for SettingFieldRenderer {}
305
306impl SettingFieldRenderer {
307 fn add_basic_renderer<T: 'static>(
308 &mut self,
309 render_control: impl Fn(
310 SettingField<T>,
311 SettingsUiFile,
312 Option<&SettingsFieldMetadata>,
313 &'static str,
314 &'static str,
315 &mut Window,
316 &mut App,
317 ) -> AnyElement
318 + 'static,
319 ) -> &mut Self {
320 self.add_renderer(
321 move |settings_window: &SettingsWindow,
322 item: &SettingItem,
323 field: SettingField<T>,
324 settings_file: SettingsUiFile,
325 metadata: Option<&SettingsFieldMetadata>,
326 sub_field: bool,
327 window: &mut Window,
328 cx: &mut Context<SettingsWindow>| {
329 let control = render_control(
330 field,
331 settings_file.clone(),
332 metadata,
333 item.title,
334 item.description,
335 window,
336 cx,
337 );
338 render_settings_item(settings_window, item, settings_file, control, sub_field, cx)
339 },
340 )
341 }
342
343 fn add_renderer<T: 'static>(
344 &mut self,
345 renderer: impl Fn(
346 &SettingsWindow,
347 &SettingItem,
348 SettingField<T>,
349 SettingsUiFile,
350 Option<&SettingsFieldMetadata>,
351 bool,
352 &mut Window,
353 &mut Context<SettingsWindow>,
354 ) -> Stateful<Div>
355 + 'static,
356 ) -> &mut Self {
357 let key = TypeId::of::<T>();
358 let renderer = Box::new(
359 move |settings_window: &SettingsWindow,
360 item: &SettingItem,
361 settings_file: SettingsUiFile,
362 metadata: Option<&SettingsFieldMetadata>,
363 sub_field: bool,
364 window: &mut Window,
365 cx: &mut Context<SettingsWindow>| {
366 let field = *item
367 .field
368 .as_ref()
369 .as_any()
370 .downcast_ref::<SettingField<T>>()
371 .unwrap();
372 renderer(
373 settings_window,
374 item,
375 field,
376 settings_file,
377 metadata,
378 sub_field,
379 window,
380 cx,
381 )
382 },
383 );
384 self.renderers.borrow_mut().insert(key, renderer);
385 self
386 }
387}
388
389struct NonFocusableHandle {
390 handle: FocusHandle,
391 _subscription: Subscription,
392}
393
394impl NonFocusableHandle {
395 fn new(tab_index: isize, tab_stop: bool, window: &mut Window, cx: &mut App) -> Entity<Self> {
396 let handle = cx.focus_handle().tab_index(tab_index).tab_stop(tab_stop);
397 Self::from_handle(handle, window, cx)
398 }
399
400 fn from_handle(handle: FocusHandle, window: &mut Window, cx: &mut App) -> Entity<Self> {
401 cx.new(|cx| {
402 let _subscription = cx.on_focus(&handle, window, {
403 move |_, window, cx| {
404 window.focus_next(cx);
405 }
406 });
407 Self {
408 handle,
409 _subscription,
410 }
411 })
412 }
413}
414
415impl Focusable for NonFocusableHandle {
416 fn focus_handle(&self, _: &App) -> FocusHandle {
417 self.handle.clone()
418 }
419}
420
421#[derive(Default)]
422struct SettingsFieldMetadata {
423 placeholder: Option<&'static str>,
424 should_do_titlecase: Option<bool>,
425 display_confirm_button: bool,
426 display_clear_button: bool,
427 confirm_on_focus_out: bool,
428 treat_missing_text_as_empty: bool,
429}
430
431pub fn init(cx: &mut App) {
432 init_renderers(cx);
433 let queue = ProjectSettingsUpdateQueue::new(cx);
434 cx.set_global(queue);
435
436 cx.on_action(|_: &OpenSettings, cx| {
437 open_settings_editor(None, None, None, cx);
438 });
439 cx.on_action(|_: &zed_actions::assistant::OpenSkillCreator, cx| {
440 open_skill_creator(pages::SkillCreatorOpenMode::Form, None, cx);
441 });
442 cx.on_action(|_: &zed_actions::assistant::CreateSkillFromUrl, cx| {
443 let initial_url = pages::skill_url_from_clipboard(cx);
444 open_skill_creator(pages::SkillCreatorOpenMode::Url { initial_url }, None, cx);
445 });
446
447 cx.observe_new(|workspace: &mut workspace::Workspace, _, _| {
448 workspace
449 .register_action(|_, action: &OpenSettingsAt, window, cx| {
450 let window_handle = window.window_handle().downcast::<MultiWorkspace>();
451 open_settings_editor_at_target(
452 Some(&action.path),
453 action.target.as_ref().map(SettingsFileTarget::from),
454 window_handle,
455 cx,
456 );
457 })
458 .register_action(|_, action: &OpenSettingsPage, window, cx| {
459 let window_handle = window.window_handle().downcast::<MultiWorkspace>();
460 open_settings_editor_to_page(
461 &action.page,
462 action.target.as_ref().map(SettingsFileTarget::from),
463 window_handle,
464 cx,
465 );
466 })
467 .register_action(|_, _: &OpenSettings, window, cx| {
468 let window_handle = window.window_handle().downcast::<MultiWorkspace>();
469 open_settings_editor(None, None, window_handle, cx);
470 })
471 .register_action(|workspace, _: &OpenProjectSettings, window, cx| {
472 let window_handle = window.window_handle().downcast::<MultiWorkspace>();
473 let target_worktree_id = workspace
474 .project()
475 .read(cx)
476 .visible_worktrees(cx)
477 .find_map(|tree| {
478 tree.read(cx)
479 .root_entry()?
480 .is_dir()
481 .then_some(tree.read(cx).id())
482 });
483 open_settings_editor(None, target_worktree_id, window_handle, cx);
484 })
485 .register_action(
486 |_, _: &zed_actions::assistant::OpenSkillCreator, window, cx| {
487 let window_handle = window.window_handle().downcast::<MultiWorkspace>();
488 open_skill_creator(pages::SkillCreatorOpenMode::Form, window_handle, cx);
489 },
490 )
491 .register_action(
492 |_, _: &zed_actions::assistant::CreateSkillFromUrl, window, cx| {
493 let window_handle = window.window_handle().downcast::<MultiWorkspace>();
494 let initial_url = pages::skill_url_from_clipboard(cx);
495 open_skill_creator(
496 pages::SkillCreatorOpenMode::Url { initial_url },
497 window_handle,
498 cx,
499 );
500 },
501 );
502 })
503 .detach();
504}
505
506fn init_renderers(cx: &mut App) {
507 cx.default_global::<SettingFieldRenderer>()
508 .add_renderer::<UnimplementedSettingField>(
509 |settings_window, item, _, settings_file, _, sub_field, _, cx| {
510 render_settings_item(
511 settings_window,
512 item,
513 settings_file,
514 Button::new("open-in-settings-file", "Edit in settings.json")
515 .style(ButtonStyle::Outlined)
516 .size(ButtonSize::Medium)
517 .tab_index(0_isize)
518 .tooltip(Tooltip::for_action_title_in(
519 "Edit in settings.json",
520 &OpenCurrentFile,
521 &settings_window.focus_handle,
522 ))
523 .on_click(cx.listener(|this, _, window, cx| {
524 this.open_current_settings_file(window, cx);
525 }))
526 .into_any_element(),
527 sub_field,
528 cx,
529 )
530 },
531 )
532 .add_basic_renderer::<bool>(render_toggle_button)
533 .add_basic_renderer::<String>(render_text_field)
534 .add_basic_renderer::<SharedString>(render_text_field)
535 .add_basic_renderer::<settings::SaturatingBool>(render_toggle_button)
536 .add_basic_renderer::<settings::CursorShape>(render_dropdown)
537 .add_basic_renderer::<settings::RestoreOnStartupBehavior>(render_dropdown)
538 .add_basic_renderer::<settings::BottomDockLayout>(render_dropdown)
539 .add_basic_renderer::<settings::OnLastWindowClosed>(render_dropdown)
540 .add_basic_renderer::<settings::CliDefaultOpenBehavior>(render_dropdown)
541 .add_basic_renderer::<settings::DefaultOpenBehavior>(render_dropdown)
542 .add_basic_renderer::<settings::CloseWindowWhenNoItems>(render_dropdown)
543 .add_basic_renderer::<settings::TextRenderingMode>(render_dropdown)
544 .add_basic_renderer::<settings::FontFamilyName>(render_font_picker)
545 .add_basic_renderer::<settings::BaseKeymapContent>(render_dropdown)
546 .add_basic_renderer::<settings::MultiCursorModifier>(render_dropdown)
547 .add_basic_renderer::<settings::HideMouseMode>(render_dropdown)
548 .add_basic_renderer::<settings::ReduceMotionMode>(render_dropdown)
549 .add_basic_renderer::<settings::CurrentLineHighlight>(render_dropdown)
550 .add_basic_renderer::<settings::ShowWhitespaceSetting>(render_dropdown)
551 .add_basic_renderer::<settings::SoftWrap>(render_dropdown)
552 .add_basic_renderer::<settings::AutoIndentMode>(render_dropdown)
553 .add_basic_renderer::<settings::ScrollBeyondLastLine>(render_dropdown)
554 .add_basic_renderer::<settings::SnippetSortOrder>(render_dropdown)
555 .add_basic_renderer::<settings::ClosePosition>(render_dropdown)
556 .add_basic_renderer::<settings::DockSide>(render_dropdown)
557 .add_basic_renderer::<settings::TerminalDockPosition>(render_dropdown)
558 .add_basic_renderer::<settings::DockPosition>(render_dropdown)
559 .add_basic_renderer::<settings::SidebarDockPosition>(render_dropdown)
560 .add_basic_renderer::<settings::GitGutterSetting>(render_dropdown)
561 .add_basic_renderer::<settings::GitHunkStyleSetting>(render_dropdown)
562 .add_basic_renderer::<settings::GitPathStyle>(render_dropdown)
563 .add_basic_renderer::<settings::InlineBlameLocation>(render_dropdown)
564 .add_basic_renderer::<settings::DiagnosticSeverityContent>(render_dropdown)
565 .add_basic_renderer::<settings::SeedQuerySetting>(render_dropdown)
566 .add_basic_renderer::<settings::DoubleClickInMultibuffer>(render_dropdown)
567 .add_basic_renderer::<settings::GoToDefinitionFallback>(render_dropdown)
568 .add_basic_renderer::<settings::GoToDefinitionScrollStrategy>(render_dropdown)
569 .add_basic_renderer::<settings::OpenResultsIn>(render_dropdown)
570 .add_basic_renderer::<settings::ActivateOnClose>(render_dropdown)
571 .add_basic_renderer::<settings::ShowDiagnostics>(render_dropdown)
572 .add_basic_renderer::<settings::ShowCloseButton>(render_dropdown)
573 .add_basic_renderer::<settings::ProjectPanelEntrySpacing>(render_dropdown)
574 .add_basic_renderer::<settings::ProjectPanelSortMode>(render_dropdown)
575 .add_basic_renderer::<settings::ProjectPanelSortOrder>(render_dropdown)
576 .add_basic_renderer::<settings::RewrapBehavior>(render_dropdown)
577 .add_basic_renderer::<settings::FormatOnSave>(render_dropdown)
578 .add_basic_renderer::<settings::LineEndingSetting>(render_dropdown)
579 .add_basic_renderer::<settings::IndentGuideColoring>(render_dropdown)
580 .add_basic_renderer::<settings::IndentGuideBackgroundColoring>(render_dropdown)
581 .add_basic_renderer::<settings::ShowDiagnostics>(render_dropdown)
582 .add_basic_renderer::<settings::WordsCompletionMode>(render_dropdown)
583 .add_basic_renderer::<settings::LspInsertMode>(render_dropdown)
584 .add_basic_renderer::<settings::CompletionDetailAlignment>(render_dropdown)
585 .add_basic_renderer::<settings::CompletionMenuItemKind>(render_dropdown)
586 .add_basic_renderer::<settings::DiffViewStyle>(render_dropdown)
587 .add_basic_renderer::<settings::AlternateScroll>(render_dropdown)
588 .add_basic_renderer::<settings::TerminalBlink>(render_dropdown)
589 .add_basic_renderer::<settings::CursorShapeContent>(render_dropdown)
590 .add_basic_renderer::<settings::EditPredictionPromptFormatContent>(render_dropdown)
591 .add_basic_renderer::<settings::EditPredictionDataCollectionChoice>(render_dropdown)
592 .add_basic_renderer::<f32>(render_editable_number_field)
593 .add_basic_renderer::<settings::AutoCompactThreshold>(render_text_field)
594 .add_basic_renderer::<u32>(render_editable_number_field)
595 .add_basic_renderer::<u64>(render_editable_number_field)
596 .add_basic_renderer::<usize>(render_editable_number_field)
597 .add_basic_renderer::<NonZero<usize>>(render_editable_number_field)
598 .add_basic_renderer::<NonZeroU32>(render_editable_number_field)
599 .add_basic_renderer::<settings::CodeFade>(render_editable_number_field)
600 .add_basic_renderer::<settings::DelayMs>(render_editable_number_field)
601 .add_basic_renderer::<settings::FontWeightContent>(render_editable_number_field)
602 .add_basic_renderer::<settings::CenteredPaddingSettings>(render_editable_number_field)
603 .add_basic_renderer::<settings::InactiveOpacity>(render_editable_number_field)
604 .add_basic_renderer::<settings::MinimumContrast>(render_editable_number_field)
605 .add_basic_renderer::<settings::ShowScrollbar>(render_dropdown)
606 .add_basic_renderer::<settings::ScrollbarDiagnostics>(render_dropdown)
607 .add_basic_renderer::<settings::ShowMinimap>(render_dropdown)
608 .add_basic_renderer::<settings::DisplayIn>(render_dropdown)
609 .add_basic_renderer::<settings::MinimapThumb>(render_dropdown)
610 .add_basic_renderer::<settings::MinimapThumbBorder>(render_dropdown)
611 .add_basic_renderer::<settings::ModeContent>(render_dropdown)
612 .add_basic_renderer::<settings::UseSystemClipboard>(render_dropdown)
613 .add_basic_renderer::<settings::VimInsertModeCursorShape>(render_dropdown)
614 .add_basic_renderer::<settings::SteppingGranularity>(render_dropdown)
615 .add_basic_renderer::<settings::NotifyWhenAgentWaiting>(render_dropdown)
616 .add_basic_renderer::<settings::PlaySoundWhenAgentDone>(render_dropdown)
617 .add_basic_renderer::<settings::ThinkingBlockDisplay>(render_dropdown)
618 .add_basic_renderer::<settings::ImageFileSizeUnit>(render_dropdown)
619 .add_basic_renderer::<settings::StatusStyle>(render_dropdown)
620 .add_basic_renderer::<settings::GitPanelClickBehavior>(render_dropdown)
621 .add_basic_renderer::<settings::GitPanelSortBy>(render_dropdown)
622 .add_basic_renderer::<settings::GitPanelGroupBy>(render_dropdown)
623 .add_basic_renderer::<settings::EncodingDisplayOptions>(render_dropdown)
624 .add_basic_renderer::<settings::PaneSplitDirectionHorizontal>(render_dropdown)
625 .add_basic_renderer::<settings::PaneSplitDirectionVertical>(render_dropdown)
626 .add_basic_renderer::<settings::PaneSplitDirectionVertical>(render_dropdown)
627 .add_basic_renderer::<settings::CodeLens>(render_dropdown)
628 .add_basic_renderer::<settings::DocumentColorsRenderMode>(render_dropdown)
629 .add_basic_renderer::<settings::ThemeSelectionDiscriminants>(render_dropdown)
630 .add_basic_renderer::<settings::ThemeAppearanceMode>(render_dropdown)
631 .add_basic_renderer::<settings::ThemeName>(render_theme_picker)
632 .add_basic_renderer::<settings::IconThemeSelectionDiscriminants>(render_dropdown)
633 .add_basic_renderer::<settings::IconThemeName>(render_icon_theme_picker)
634 .add_basic_renderer::<settings::BufferLineHeightDiscriminants>(render_dropdown)
635 .add_basic_renderer::<settings::AutosaveSettingDiscriminants>(render_dropdown)
636 .add_basic_renderer::<settings::WorkingDirectoryDiscriminants>(render_dropdown)
637 .add_basic_renderer::<settings::IncludeIgnoredContent>(render_dropdown)
638 .add_basic_renderer::<settings::ShowIndentGuides>(render_dropdown)
639 .add_basic_renderer::<settings::ShellDiscriminants>(render_dropdown)
640 .add_basic_renderer::<settings::EditPredictionsMode>(render_dropdown)
641 .add_basic_renderer::<settings::RelativeLineNumbers>(render_dropdown)
642 .add_basic_renderer::<settings::WindowDecorations>(render_dropdown)
643 .add_basic_renderer::<settings::WindowButtonLayoutContentDiscriminants>(render_dropdown)
644 .add_basic_renderer::<settings::ScanSymlinksSetting>(render_dropdown)
645 .add_basic_renderer::<settings::FontSize>(render_editable_number_field)
646 .add_basic_renderer::<settings::OllamaModelName>(render_ollama_model_picker)
647 .add_basic_renderer::<settings::SemanticTokens>(render_dropdown)
648 .add_basic_renderer::<settings::DocumentFoldingRanges>(render_dropdown)
649 .add_basic_renderer::<settings::DocumentSymbols>(render_dropdown)
650 .add_basic_renderer::<settings::AudioInputDeviceName>(render_input_audio_device_dropdown)
651 .add_basic_renderer::<settings::AudioOutputDeviceName>(render_output_audio_device_dropdown)
652 .add_basic_renderer::<settings::TerminalBell>(render_dropdown)
653 // please semicolon stay on next line
654 ;
655}
656
657#[derive(Clone, Copy)]
658enum SettingsFileTarget {
659 User,
660 Project(WorktreeId),
661}
662
663impl From<&OpenSettingsAtTarget> for SettingsFileTarget {
664 fn from(target: &OpenSettingsAtTarget) -> Self {
665 match target {
666 OpenSettingsAtTarget::User => Self::User,
667 OpenSettingsAtTarget::Project { worktree_id } => {
668 Self::Project(WorktreeId::from_usize(*worktree_id))
669 }
670 }
671 }
672}
673
674pub fn open_settings_editor(
675 path: Option<&str>,
676 target_worktree_id: Option<WorktreeId>,
677 workspace_handle: Option<WindowHandle<MultiWorkspace>>,
678 cx: &mut App,
679) {
680 open_settings_editor_at_target(
681 path,
682 target_worktree_id.map(SettingsFileTarget::Project),
683 workspace_handle,
684 cx,
685 );
686}
687
688fn select_settings_file_target(
689 target_file: SettingsFileTarget,
690 settings_window: &mut SettingsWindow,
691 window: &mut Window,
692 cx: &mut Context<SettingsWindow>,
693) {
694 let file_index = settings_window
695 .files
696 .iter()
697 .position(|(file, _)| match target_file {
698 SettingsFileTarget::User => matches!(file, SettingsUiFile::User),
699 SettingsFileTarget::Project(worktree_id) => file.worktree_id() == Some(worktree_id),
700 });
701 if let Some(file_index) = file_index {
702 settings_window.change_file(file_index, window, cx);
703 }
704}
705
706fn open_settings_editor_to_page(
707 page: &str,
708 target_file: Option<SettingsFileTarget>,
709 workspace_handle: Option<WindowHandle<MultiWorkspace>>,
710 cx: &mut App,
711) {
712 let page = page.to_string();
713 open_settings_editor_with(workspace_handle, cx, move |settings_window, window, cx| {
714 if let Some(target_file) = target_file {
715 select_settings_file_target(target_file, settings_window, window, cx);
716 }
717
718 settings_window.opening_link = false;
719 settings_window.search_bar.update(cx, |editor, cx| {
720 editor.set_text(String::new(), window, cx);
721 });
722 for page_filter in &mut settings_window.filter_table {
723 page_filter.fill(true);
724 }
725 settings_window.has_query = false;
726 settings_window.filter_matches_to_file();
727
728 let Some(navbar_entry_index) = settings_window
729 .navbar_entries
730 .iter()
731 .position(|entry| entry.is_root && entry.title.eq_ignore_ascii_case(&page))
732 else {
733 log::error!("settings page not found: {page}");
734 return;
735 };
736
737 settings_window.open_and_scroll_to_navbar_entry(
738 navbar_entry_index,
739 None,
740 false,
741 window,
742 cx,
743 );
744 });
745}
746
747fn open_settings_editor_at_target(
748 path: Option<&str>,
749 target_file: Option<SettingsFileTarget>,
750 workspace_handle: Option<WindowHandle<MultiWorkspace>>,
751 cx: &mut App,
752) {
753 /// Assumes a settings GUI window is already open
754 fn open_path(
755 path: &str,
756 settings_window: &mut SettingsWindow,
757 window: &mut Window,
758 cx: &mut Context<SettingsWindow>,
759 ) {
760 if path.starts_with("languages.$(language)") {
761 log::error!("language-specific settings links are not currently supported");
762 return;
763 }
764
765 let query = format!("#{path}");
766 let indices = settings_window.filter_by_json_path(&query);
767
768 settings_window.opening_link = true;
769 settings_window.search_bar.update(cx, |editor, cx| {
770 editor.set_text(query.clone(), window, cx);
771 });
772 settings_window.apply_match_indices(indices.iter().copied(), &query);
773
774 if indices.len() == 1
775 && let Some(search_index) = settings_window.search_index.as_ref()
776 {
777 let SearchKeyLUTEntry {
778 page_index,
779 item_index,
780 header_index,
781 ..
782 } = search_index.key_lut[indices[0]];
783 let page = &settings_window.pages[page_index];
784 let item = &page.items[item_index];
785
786 if settings_window.filter_table[page_index][item_index]
787 && let SettingsPageItem::SubPageLink(link) = item
788 && let SettingsPageItem::SectionHeader(header) = page.items[header_index]
789 {
790 settings_window.push_sub_page(link.clone(), SharedString::from(header), window, cx);
791 }
792 }
793
794 cx.notify();
795 }
796
797 let path = path.map(ToOwned::to_owned);
798 open_settings_editor_with(workspace_handle, cx, move |settings_window, window, cx| {
799 if let Some(target_file) = target_file {
800 select_settings_file_target(target_file, settings_window, window, cx);
801 }
802 if let Some(path) = path {
803 open_path(&path, settings_window, window, cx);
804 } else if target_file.is_some() {
805 cx.notify();
806 }
807 });
808}
809
810pub fn open_skill_creator(
811 open_mode: pages::SkillCreatorOpenMode,
812 workspace_handle: Option<WindowHandle<MultiWorkspace>>,
813 cx: &mut App,
814) {
815 open_settings_editor_with(workspace_handle, cx, |settings_window, window, cx| {
816 settings_window.navigate_to_skill_creator(open_mode, window, cx);
817 });
818}
819
820fn open_settings_editor_with(
821 workspace_handle: Option<WindowHandle<MultiWorkspace>>,
822 cx: &mut App,
823 callback: impl FnOnce(&mut SettingsWindow, &mut Window, &mut Context<SettingsWindow>) + 'static,
824) {
825 telemetry::event!("Settings Viewed");
826
827 let existing_window = cx
828 .windows()
829 .into_iter()
830 .find_map(|window| window.downcast::<SettingsWindow>());
831
832 if let Some(existing_window) = existing_window {
833 existing_window
834 .update(cx, |settings_window, window, cx| {
835 settings_window.original_window = workspace_handle;
836
837 window.activate_window();
838 callback(settings_window, window, cx);
839 })
840 .ok();
841 return;
842 }
843
844 // We have to defer this to get the workspace off the stack.
845 cx.defer(move |cx| {
846 let current_rem_size: f32 = theme_settings::ThemeSettings::get_global(cx)
847 .ui_font_size(cx)
848 .into();
849
850 let default_bounds = DEFAULT_ADDITIONAL_WINDOW_SIZE;
851 let default_rem_size = 16.0;
852 let scale_factor = current_rem_size / default_rem_size;
853 let scaled_bounds: gpui::Size<Pixels> = default_bounds.map(|axis| axis * scale_factor);
854
855 let app_id = ReleaseChannel::global(cx).app_id();
856 let window_decorations = match std::env::var("ZED_WINDOW_DECORATIONS") {
857 Ok(val) if val == "server" => gpui::WindowDecorations::Server,
858 Ok(val) if val == "client" => gpui::WindowDecorations::Client,
859 _ => match WorkspaceSettings::get_global(cx).window_decorations {
860 settings::WindowDecorations::Server => gpui::WindowDecorations::Server,
861 settings::WindowDecorations::Client => gpui::WindowDecorations::Client,
862 },
863 };
864
865 cx.open_window(
866 WindowOptions {
867 titlebar: Some(TitlebarOptions {
868 title: Some("Omega — Settings".into()),
869 appears_transparent: true,
870 traffic_light_position: Some(point(px(12.0), px(12.0))),
871 }),
872 focus: true,
873 show: true,
874 is_movable: true,
875 kind: gpui::WindowKind::Normal,
876 window_background: cx.theme().window_background_appearance(),
877 app_id: Some(app_id.to_owned()),
878 window_decorations: Some(window_decorations),
879 window_min_size: Some(gpui::Size {
880 // Do not make the settings window thinner than this,
881 // otherwise, the space used to display the actual content
882 // gets so small that certain sections grow too tall due
883 // to intense text wrapping.
884 width: SIDEBAR_WIDTH + CONTENT_MIN_WIDTH,
885 height: px(240.0),
886 }),
887 window_bounds: Some(WindowBounds::centered(scaled_bounds, cx)),
888 ..Default::default()
889 },
890 |window, cx| {
891 let settings_window =
892 cx.new(|cx| SettingsWindow::new(workspace_handle, window, cx));
893 settings_window.update(cx, |settings_window, cx| {
894 callback(settings_window, window, cx);
895 });
896
897 settings_window
898 },
899 )
900 .log_err();
901 });
902}
903
904/// The current sub page path that is selected.
905/// If this is empty the selected page is rendered,
906/// otherwise the last sub page gets rendered.
907///
908/// Global so that `pick` and `write` callbacks can access it
909/// and use it to dynamically render sub pages (e.g. for language settings)
910static ACTIVE_LANGUAGE: LazyLock<RwLock<Option<SharedString>>> =
911 LazyLock::new(|| RwLock::new(Option::None));
912
913fn active_language() -> Option<SharedString> {
914 ACTIVE_LANGUAGE
915 .read()
916 .ok()
917 .and_then(|language| language.clone())
918}
919
920fn active_language_mut() -> Option<std::sync::RwLockWriteGuard<'static, Option<SharedString>>> {
921 ACTIVE_LANGUAGE.write().ok()
922}
923
924pub struct SettingsWindow {
925 title_bar: Option<Entity<PlatformTitleBar>>,
926 original_window: Option<WindowHandle<MultiWorkspace>>,
927 files: Vec<(SettingsUiFile, FocusHandle)>,
928 worktree_root_dirs: HashMap<WorktreeId, String>,
929 current_file: SettingsUiFile,
930 pages: Vec<SettingsPage>,
931 sub_page_stack: Vec<SubPage>,
932 opening_link: bool,
933 search_bar: Entity<Editor>,
934 search_task: Option<Task<()>>,
935 /// Cached settings file buffers to avoid repeated disk I/O on each settings change
936 project_setting_file_buffers: HashMap<ProjectPath, Entity<Buffer>>,
937 /// Index into navbar_entries
938 navbar_entry: usize,
939 navbar_entries: Vec<NavBarEntry>,
940 navbar_scroll_handle: UniformListScrollHandle,
941 /// [page_index][page_item_index] will be false
942 /// when the item is filtered out either by searches
943 /// or by the current file
944 navbar_focus_subscriptions: Vec<gpui::Subscription>,
945 filter_table: Vec<Vec<bool>>,
946 has_query: bool,
947 content_handles: Vec<Vec<Entity<NonFocusableHandle>>>,
948 focus_handle: FocusHandle,
949 navbar_focus_handle: Entity<NonFocusableHandle>,
950 content_focus_handle: Entity<NonFocusableHandle>,
951 files_focus_handle: FocusHandle,
952 search_index: Option<Arc<SearchIndex>>,
953 list_state: ListState,
954 shown_errors: HashSet<String>,
955 pub(crate) hidden_deleted_skill_directory_paths: HashSet<PathBuf>,
956 pub(crate) regex_validation_error: Option<String>,
957 pub(crate) sandbox_host_validation_error: Option<String>,
958 last_copied_link_path: Option<&'static str>,
959 /// Cached configuration views per provider, created lazily.
960 pub(crate) provider_configuration_views:
961 HashMap<language_model::LanguageModelProviderId, gpui::AnyView>,
962 /// The provider whose configuration sub-page is currently open, if any.
963 pub(crate) configuring_provider: Option<language_model::LanguageModelProviderId>,
964 /// Directory path of the skill whose share link was most recently copied,
965 /// used to show a transient "copied" checkmark on its share button.
966 pub(crate) last_copied_skill_directory_path: Option<PathBuf>,
967 /// State for the active "add OpenAI/Anthropic-compatible provider" form sub-page, if open.
968 pub(crate) llm_provider_form: Option<LlmProviderForm>,
969 /// Stable focus handle for the LLM "Add Provider" button, so it can show a
970 /// focus ring when the page auto-focuses it on open (which happens via mouse,
971 /// where `focus_visible` styling would otherwise be suppressed).
972 pub(crate) llm_provider_add_focus_handle: FocusHandle,
973 /// State for the active "add/edit custom MCP server" form sub-page, if open.
974 pub(crate) mcp_server_form: Option<McpServerForm>,
975 /// Stable focus handle for the MCP "Add Server" button, so it can show a
976 /// focus ring when the page auto-focuses it on open (which happens via mouse,
977 /// where `focus_visible` styling would otherwise be suppressed).
978 pub(crate) mcp_add_server_focus_handle: FocusHandle,
979 /// State for the active "add/edit custom external agent" form sub-page, if open.
980 pub(crate) custom_agent_form: Option<CustomAgentForm>,
981 /// Stable focus handle for the external agents "Add Agent" button, so it can
982 /// show a focus ring when the page auto-focuses it on open (which happens via
983 /// mouse, where `focus_visible` styling would otherwise be suppressed).
984 pub(crate) external_agent_add_focus_handle: FocusHandle,
985 /// Harness maintenance state per external agent, for the External Agents
986 /// page. omega#81, `OMEGA-DELTA-0033`.
987 ///
988 /// Cached rather than computed during render because deciding it means
989 /// hashing an installed tree, and a settings row must not do that on every
990 /// frame. Refreshed when the window opens, when settings change, and after
991 /// any pin the owner takes or removes — the three moments it can differ.
992 pub(crate) harness_maintenance:
993 ::collections::HashMap<project::AgentId, omega_harness::HarnessFrontDoorState>,
994 pub(crate) harness_maintenance_task: Option<Task<()>>,
995 /// What went wrong the last time the owner pressed a maintenance control.
996 ///
997 /// Rendered beside the control. A pin write that fails silently is the same
998 /// defect as a refusal nobody can see.
999 pub(crate) harness_maintenance_error: Option<(project::AgentId, SharedString)>,
1000 skill_creator_page: Option<(Entity<pages::SkillCreatorPage>, Subscription)>,
1001}
1002
1003struct SearchDocument {
1004 id: usize,
1005 words: Vec<String>,
1006}
1007
1008struct SearchIndex {
1009 documents: Vec<SearchDocument>,
1010 fuzzy_match_candidates: Vec<StringMatchCandidate>,
1011 key_lut: Vec<SearchKeyLUTEntry>,
1012}
1013
1014struct SearchKeyLUTEntry {
1015 page_index: usize,
1016 header_index: usize,
1017 item_index: usize,
1018 json_path: Option<&'static str>,
1019}
1020
1021struct SubPage {
1022 link: SubPageLink,
1023 section_header: SharedString,
1024 scroll_handle: ScrollHandle,
1025}
1026
1027impl SubPage {
1028 fn new(link: SubPageLink, section_header: SharedString) -> Self {
1029 if link.r#type == SubPageType::Language
1030 && let Some(mut active_language_global) = active_language_mut()
1031 {
1032 active_language_global.replace(link.title.clone());
1033 }
1034
1035 SubPage {
1036 link,
1037 section_header,
1038 scroll_handle: ScrollHandle::new(),
1039 }
1040 }
1041}
1042
1043impl Drop for SubPage {
1044 fn drop(&mut self) {
1045 if self.link.r#type == SubPageType::Language
1046 && let Some(mut active_language_global) = active_language_mut()
1047 && active_language_global
1048 .as_ref()
1049 .is_some_and(|language_name| language_name == &self.link.title)
1050 {
1051 active_language_global.take();
1052 }
1053 }
1054}
1055
1056#[derive(Debug)]
1057struct NavBarEntry {
1058 title: &'static str,
1059 is_root: bool,
1060 expanded: bool,
1061 page_index: usize,
1062 item_index: Option<usize>,
1063 focus_handle: FocusHandle,
1064}
1065
1066struct SettingsPage {
1067 title: &'static str,
1068 items: Box<[SettingsPageItem]>,
1069}
1070
1071#[derive(PartialEq)]
1072enum SettingsPageItem {
1073 SectionHeader(&'static str),
1074 SettingItem(SettingItem),
1075 SubPageLink(SubPageLink),
1076 DynamicItem(DynamicItem),
1077 ActionLink(ActionLink),
1078}
1079
1080impl std::fmt::Debug for SettingsPageItem {
1081 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1082 match self {
1083 SettingsPageItem::SectionHeader(header) => write!(f, "SectionHeader({})", header),
1084 SettingsPageItem::SettingItem(setting_item) => {
1085 write!(f, "SettingItem({})", setting_item.title)
1086 }
1087 SettingsPageItem::SubPageLink(sub_page_link) => {
1088 write!(f, "SubPageLink({})", sub_page_link.title)
1089 }
1090 SettingsPageItem::DynamicItem(dynamic_item) => {
1091 write!(f, "DynamicItem({})", dynamic_item.discriminant.title)
1092 }
1093 SettingsPageItem::ActionLink(action_link) => {
1094 write!(f, "ActionLink({})", action_link.title)
1095 }
1096 }
1097 }
1098}
1099
1100impl SettingsPageItem {
1101 fn header_text(&self) -> Option<&'static str> {
1102 match self {
1103 SettingsPageItem::SectionHeader(header) => Some(header),
1104 _ => None,
1105 }
1106 }
1107
1108 fn render(
1109 &self,
1110 settings_window: &SettingsWindow,
1111 item_index: usize,
1112 bottom_border: bool,
1113 extra_bottom_padding: bool,
1114 window: &mut Window,
1115 cx: &mut Context<SettingsWindow>,
1116 ) -> AnyElement {
1117 let file = settings_window.current_file.clone();
1118
1119 let apply_padding = |element: Stateful<Div>| -> Stateful<Div> {
1120 let element = element.pt_4();
1121 if extra_bottom_padding {
1122 element.pb_10()
1123 } else {
1124 element.pb_4()
1125 }
1126 };
1127
1128 let mut render_setting_item_inner =
1129 |setting_item: &SettingItem,
1130 padding: bool,
1131 sub_field: bool,
1132 cx: &mut Context<SettingsWindow>| {
1133 let renderer = cx.default_global::<SettingFieldRenderer>().clone();
1134 let (_, found) = setting_item.field.file_set_in(file.clone(), cx);
1135
1136 let renderers = renderer.renderers.borrow();
1137
1138 let field_renderer =
1139 renderers.get(&AnySettingField::type_id(setting_item.field.as_ref()));
1140 let field_renderer_or_warning =
1141 field_renderer.ok_or("NO RENDERER").and_then(|renderer| {
1142 if cfg!(debug_assertions) && !found {
1143 Err("NO DEFAULT")
1144 } else {
1145 Ok(renderer)
1146 }
1147 });
1148
1149 let field = match field_renderer_or_warning {
1150 Ok(field_renderer) => window.with_id(item_index, |window| {
1151 field_renderer(
1152 settings_window,
1153 setting_item,
1154 file.clone(),
1155 setting_item.metadata.as_deref(),
1156 sub_field,
1157 window,
1158 cx,
1159 )
1160 }),
1161 Err(warning) => render_settings_item(
1162 settings_window,
1163 setting_item,
1164 file.clone(),
1165 Button::new("error-warning", warning)
1166 .style(ButtonStyle::Outlined)
1167 .size(ButtonSize::Medium)
1168 .start_icon(Icon::new(IconName::Debug).color(Color::Error))
1169 .tab_index(0_isize)
1170 .tooltip(Tooltip::text(setting_item.field.type_name()))
1171 .into_any_element(),
1172 sub_field,
1173 cx,
1174 ),
1175 };
1176
1177 let field = if padding {
1178 field.map(apply_padding)
1179 } else {
1180 field
1181 };
1182
1183 (field, field_renderer_or_warning.is_ok())
1184 };
1185
1186 match self {
1187 SettingsPageItem::SectionHeader(header) => {
1188 SettingsSectionHeader::new(SharedString::new_static(header)).into_any_element()
1189 }
1190 SettingsPageItem::SettingItem(setting_item) => {
1191 let (field_with_padding, _) =
1192 render_setting_item_inner(setting_item, true, false, cx);
1193
1194 v_flex()
1195 .group("setting-item")
1196 .px_8()
1197 .child(field_with_padding)
1198 .when(bottom_border, |this| this.child(Divider::horizontal()))
1199 .into_any_element()
1200 }
1201 SettingsPageItem::SubPageLink(sub_page_link) => v_flex()
1202 .group("setting-item")
1203 .px_8()
1204 .child(
1205 h_flex()
1206 .id(sub_page_link.title.clone())
1207 .w_full()
1208 .min_w_0()
1209 .justify_between()
1210 .map(apply_padding)
1211 .child(
1212 v_flex()
1213 .relative()
1214 .w_full()
1215 .max_w_1_2()
1216 .child(Label::new(sub_page_link.title.clone()))
1217 .when_some(
1218 sub_page_link.description.as_ref(),
1219 |this, description| {
1220 this.child(
1221 Label::new(description.clone())
1222 .size(LabelSize::Small)
1223 .color(Color::Muted),
1224 )
1225 },
1226 ),
1227 )
1228 .child(
1229 Button::new(
1230 ("sub-page".into(), sub_page_link.title.clone()),
1231 "Configure",
1232 )
1233 .aria_label(format!("Configure {}", sub_page_link.title))
1234 .tab_index(0_isize)
1235 .end_icon(
1236 Icon::new(IconName::ChevronRight)
1237 .size(IconSize::Small)
1238 .color(Color::Muted),
1239 )
1240 .style(ButtonStyle::OutlinedGhost)
1241 .size(ButtonSize::Medium)
1242 .on_click({
1243 let sub_page_link = sub_page_link.clone();
1244 cx.listener(move |this, _, window, cx| {
1245 let header_text = this
1246 .sub_page_stack
1247 .last()
1248 .map(|sub_page| sub_page.link.title.clone())
1249 .or_else(|| {
1250 this.current_page()
1251 .items
1252 .iter()
1253 .take(item_index)
1254 .rev()
1255 .find_map(|item| {
1256 item.header_text().map(SharedString::new_static)
1257 })
1258 });
1259
1260 let Some(header) = header_text else {
1261 unreachable!(
1262 "All items always have a section header above them"
1263 )
1264 };
1265
1266 this.push_sub_page(sub_page_link.clone(), header, window, cx)
1267 })
1268 }),
1269 )
1270 .child(render_settings_item_link(
1271 sub_page_link.title.clone(),
1272 sub_page_link.json_path,
1273 false,
1274 settings_window,
1275 cx,
1276 )),
1277 )
1278 .when(bottom_border, |this| this.child(Divider::horizontal()))
1279 .into_any_element(),
1280 SettingsPageItem::DynamicItem(DynamicItem {
1281 discriminant: discriminant_setting_item,
1282 pick_discriminant,
1283 fields,
1284 }) => {
1285 let file = file.to_settings();
1286 let discriminant = SettingsStore::global(cx)
1287 .get_value_from_file(file, *pick_discriminant)
1288 .1;
1289
1290 let (discriminant_element, rendered_ok) =
1291 render_setting_item_inner(discriminant_setting_item, true, false, cx);
1292
1293 let has_sub_fields =
1294 rendered_ok && discriminant.is_some_and(|d| !fields[d].is_empty());
1295
1296 let mut content = v_flex()
1297 .id("dynamic-item")
1298 .child(
1299 div()
1300 .group("setting-item")
1301 .px_8()
1302 .child(discriminant_element.when(has_sub_fields, |this| this.pb_4())),
1303 )
1304 .when(!has_sub_fields && bottom_border, |this| {
1305 this.child(h_flex().px_8().child(Divider::horizontal()))
1306 });
1307
1308 if rendered_ok {
1309 let discriminant =
1310 discriminant.expect("This should be Some if rendered_ok is true");
1311 let sub_fields = &fields[discriminant];
1312 let sub_field_count = sub_fields.len();
1313
1314 for (index, field) in sub_fields.iter().enumerate() {
1315 let is_last_sub_field = index == sub_field_count - 1;
1316 let (raw_field, _) = render_setting_item_inner(field, false, true, cx);
1317
1318 content = content.child(
1319 raw_field
1320 .group("setting-sub-item")
1321 .mx_8()
1322 .p_4()
1323 .border_t_1()
1324 .when(is_last_sub_field, |this| this.border_b_1())
1325 .when(is_last_sub_field && extra_bottom_padding, |this| {
1326 this.mb_8()
1327 })
1328 .border_dashed()
1329 .border_color(cx.theme().colors().border_variant)
1330 .bg(cx.theme().colors().element_background.opacity(0.2)),
1331 );
1332 }
1333 }
1334
1335 return content.into_any_element();
1336 }
1337 SettingsPageItem::ActionLink(action_link) => v_flex()
1338 .group("setting-item")
1339 .px_8()
1340 .child(
1341 h_flex()
1342 .id(action_link.title.clone())
1343 .w_full()
1344 .min_w_0()
1345 .justify_between()
1346 .map(apply_padding)
1347 .child(
1348 v_flex()
1349 .relative()
1350 .w_full()
1351 .max_w_1_2()
1352 .child(Label::new(action_link.title.clone()))
1353 .when_some(
1354 action_link.description.as_ref(),
1355 |this, description| {
1356 this.child(
1357 Label::new(description.clone())
1358 .size(LabelSize::Small)
1359 .color(Color::Muted),
1360 )
1361 },
1362 ),
1363 )
1364 .child(
1365 Button::new(
1366 ("action-link".into(), action_link.title.clone()),
1367 action_link.button_text.clone(),
1368 )
1369 .tab_index(0_isize)
1370 .end_icon(
1371 Icon::new(IconName::ArrowUpRight)
1372 .size(IconSize::Small)
1373 .color(Color::Muted),
1374 )
1375 .style(ButtonStyle::OutlinedGhost)
1376 .size(ButtonSize::Medium)
1377 .on_click({
1378 let on_click = action_link.on_click.clone();
1379 cx.listener(move |this, _, window, cx| {
1380 on_click(this, window, cx);
1381 })
1382 }),
1383 ),
1384 )
1385 .when(bottom_border, |this| this.child(Divider::horizontal()))
1386 .into_any_element(),
1387 }
1388 }
1389}
1390
1391/// Shared layout for both JSON-backed and non-JSON-backed setting items.
1392///
1393/// Renders title + description on the left, control on the right, with
1394/// optional reset button and copy-link icon.
1395fn render_settings_item_layout(
1396 settings_window: &SettingsWindow,
1397 title: &'static str,
1398 description: &'static str,
1399 control: AnyElement,
1400 reset_fn: Option<Box<dyn Fn(&mut Window, &mut App)>>,
1401 modified_in: Option<String>,
1402 json_path: Option<&'static str>,
1403 sub_field: bool,
1404 cx: &mut Context<'_, SettingsWindow>,
1405) -> Stateful<Div> {
1406 // Note: the row itself is intentionally not exposed as a labeled group.
1407 // Each control names and describes itself (via the setting title and
1408 // description), so adding a group with the same label here would make
1409 // screen readers announce the setting name twice.
1410 h_flex()
1411 .id(title)
1412 .min_w_0()
1413 .justify_between()
1414 .child(
1415 v_flex()
1416 .relative()
1417 .w_full()
1418 .max_w_2_3()
1419 .min_w_0()
1420 .child(
1421 h_flex()
1422 .w_full()
1423 .gap_1()
1424 .child(Label::new(SharedString::new_static(title)))
1425 .when_some(reset_fn, |this, reset_to_default| {
1426 this.child(
1427 IconButton::new("reset-to-default-btn", IconName::Undo)
1428 .icon_color(Color::Muted)
1429 .icon_size(IconSize::Small)
1430 .aria_label("Reset to Default")
1431 .tooltip(Tooltip::text("Reset to Default"))
1432 .on_click(move |_, window, cx| {
1433 reset_to_default(window, cx);
1434 }),
1435 )
1436 })
1437 .when_some(modified_in, |this, modified_in| {
1438 this.child(
1439 Label::new(format!("\u{2014} Modified in {modified_in}"))
1440 .color(Color::Muted)
1441 .size(LabelSize::Small),
1442 )
1443 }),
1444 )
1445 .child(
1446 Label::new(SharedString::new_static(description))
1447 .size(LabelSize::Small)
1448 .color(Color::Muted)
1449 .render_code_spans(),
1450 ),
1451 )
1452 .child(control)
1453 .when(settings_window.sub_page_stack.is_empty(), |this| {
1454 this.child(render_settings_item_link(
1455 description,
1456 json_path,
1457 sub_field,
1458 settings_window,
1459 cx,
1460 ))
1461 })
1462}
1463
1464fn render_settings_item(
1465 settings_window: &SettingsWindow,
1466 setting_item: &SettingItem,
1467 file: SettingsUiFile,
1468 control: AnyElement,
1469 sub_field: bool,
1470 cx: &mut Context<'_, SettingsWindow>,
1471) -> Stateful<Div> {
1472 let (found_in_file, _) = setting_item.field.file_set_in(file.clone(), cx);
1473 let file_set_in = SettingsUiFile::from_settings(found_in_file.clone());
1474
1475 let reset_fn = if sub_field {
1476 None
1477 } else {
1478 setting_item
1479 .field
1480 .reset_to_default_fn(&file, &found_in_file, cx)
1481 };
1482
1483 let modified_in = file_set_in
1484 .filter(|f| f != &file)
1485 .and_then(|f| settings_window.display_name(&f));
1486
1487 let control = if setting_item.field.is_overridden_by_organization(cx) {
1488 h_flex()
1489 .gap_2()
1490 .child(
1491 div()
1492 .id(format!(
1493 "{}-organization-configuration-warning",
1494 setting_item.title
1495 ))
1496 .child(
1497 Icon::new(IconName::Warning)
1498 .size(IconSize::Small)
1499 .color(Color::Warning),
1500 )
1501 .tooltip(|_, cx| {
1502 Tooltip::with_meta(
1503 "Overridden by Organization",
1504 None,
1505 "Contact your organization admins to adjust this setting.",
1506 cx,
1507 )
1508 }),
1509 )
1510 .child(control)
1511 .into_any_element()
1512 } else {
1513 control
1514 };
1515
1516 render_settings_item_layout(
1517 settings_window,
1518 setting_item.title,
1519 setting_item.description,
1520 control,
1521 reset_fn,
1522 modified_in,
1523 setting_item.field.json_path(),
1524 sub_field,
1525 cx,
1526 )
1527}
1528
1529fn render_settings_item_link(
1530 id: impl Into<ElementId>,
1531 json_path: Option<&'static str>,
1532 sub_field: bool,
1533 settings_window: &SettingsWindow,
1534 cx: &mut Context<'_, SettingsWindow>,
1535) -> impl IntoElement {
1536 let copied_link_matches =
1537 json_path.is_some() && json_path == settings_window.last_copied_link_path;
1538
1539 let (link_icon, link_icon_color) = if copied_link_matches {
1540 (IconName::Check, Color::Success)
1541 } else {
1542 (IconName::Link, Color::Muted)
1543 };
1544
1545 div()
1546 .absolute()
1547 .top(rems_from_px(18.))
1548 .map(|this| {
1549 if sub_field {
1550 this.visible_on_hover("setting-sub-item")
1551 .left(rems_from_px(-8.5))
1552 } else {
1553 this.visible_on_hover("setting-item")
1554 .left(rems_from_px(-22.))
1555 }
1556 })
1557 .child(
1558 IconButton::new((id.into(), "copy-link-btn"), link_icon)
1559 .icon_color(link_icon_color)
1560 .icon_size(IconSize::Small)
1561 .shape(IconButtonShape::Square)
1562 .aria_label("Copy Link")
1563 .tooltip(Tooltip::text("Copy Link"))
1564 .when_some(json_path, |this, path| {
1565 this.on_click(cx.listener(move |this, _, _, cx| {
1566 let link = format!("zed://settings/{}", path);
1567 cx.write_to_clipboard(ClipboardItem::new_string(link));
1568 this.last_copied_link_path = Some(path);
1569 cx.notify();
1570 }))
1571 }),
1572 )
1573}
1574
1575struct SettingItem {
1576 title: &'static str,
1577 description: &'static str,
1578 field: Box<dyn AnySettingField>,
1579 metadata: Option<Box<SettingsFieldMetadata>>,
1580 files: FileMask,
1581}
1582
1583struct DynamicItem {
1584 discriminant: SettingItem,
1585 pick_discriminant: fn(&SettingsContent) -> Option<usize>,
1586 fields: Vec<Vec<SettingItem>>,
1587}
1588
1589impl PartialEq for DynamicItem {
1590 fn eq(&self, other: &Self) -> bool {
1591 self.discriminant == other.discriminant && self.fields == other.fields
1592 }
1593}
1594
1595#[derive(PartialEq, Eq, Clone, Copy)]
1596struct FileMask(u8);
1597
1598impl std::fmt::Debug for FileMask {
1599 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1600 write!(f, "FileMask(")?;
1601 let mut items = vec![];
1602
1603 if self.contains(USER) {
1604 items.push("USER");
1605 }
1606 if self.contains(PROJECT) {
1607 items.push("LOCAL");
1608 }
1609 if self.contains(SERVER) {
1610 items.push("SERVER");
1611 }
1612
1613 write!(f, "{})", items.join(" | "))
1614 }
1615}
1616
1617const USER: FileMask = FileMask(1 << 0);
1618const PROJECT: FileMask = FileMask(1 << 2);
1619const SERVER: FileMask = FileMask(1 << 3);
1620
1621impl std::ops::BitAnd for FileMask {
1622 type Output = Self;
1623
1624 fn bitand(self, other: Self) -> Self {
1625 Self(self.0 & other.0)
1626 }
1627}
1628
1629impl std::ops::BitOr for FileMask {
1630 type Output = Self;
1631
1632 fn bitor(self, other: Self) -> Self {
1633 Self(self.0 | other.0)
1634 }
1635}
1636
1637impl FileMask {
1638 fn contains(&self, other: FileMask) -> bool {
1639 self.0 & other.0 != 0
1640 }
1641}
1642
1643impl PartialEq for SettingItem {
1644 fn eq(&self, other: &Self) -> bool {
1645 self.title == other.title
1646 && self.description == other.description
1647 && (match (&self.metadata, &other.metadata) {
1648 (None, None) => true,
1649 (Some(m1), Some(m2)) => m1.placeholder == m2.placeholder,
1650 _ => false,
1651 })
1652 }
1653}
1654
1655#[derive(Clone, PartialEq, Default)]
1656enum SubPageType {
1657 Language,
1658 SkillCreator,
1659 #[default]
1660 Other,
1661}
1662
1663#[derive(Clone)]
1664struct SubPageLink {
1665 title: SharedString,
1666 r#type: SubPageType,
1667 description: Option<SharedString>,
1668 search_aliases: &'static [&'static str],
1669 /// See [`SettingField.json_path`]
1670 json_path: Option<&'static str>,
1671 /// Whether or not the settings in this sub page are configurable in settings.json
1672 /// Removes the "Edit in settings.json" button from the page.
1673 in_json: bool,
1674 files: FileMask,
1675 render:
1676 fn(&SettingsWindow, &ScrollHandle, &mut Window, &mut Context<SettingsWindow>) -> AnyElement,
1677}
1678
1679impl PartialEq for SubPageLink {
1680 fn eq(&self, other: &Self) -> bool {
1681 self.title == other.title
1682 }
1683}
1684
1685#[derive(Clone)]
1686struct ActionLink {
1687 title: SharedString,
1688 description: Option<SharedString>,
1689 button_text: SharedString,
1690 on_click: Arc<dyn Fn(&mut SettingsWindow, &mut Window, &mut App) + Send + Sync>,
1691 files: FileMask,
1692}
1693
1694impl PartialEq for ActionLink {
1695 fn eq(&self, other: &Self) -> bool {
1696 self.title == other.title
1697 }
1698}
1699
1700fn all_language_names(cx: &App) -> Vec<SharedString> {
1701 let state = workspace::AppState::global(cx);
1702 state
1703 .languages
1704 .language_names()
1705 .into_iter()
1706 .filter(|name| name.as_ref() != "Keybind Context")
1707 .map(Into::into)
1708 .collect()
1709}
1710
1711#[allow(unused)]
1712#[derive(Clone, PartialEq, Debug)]
1713enum SettingsUiFile {
1714 User, // Uses all settings.
1715 Project((WorktreeId, Arc<RelPath>)), // Has a special name, and special set of settings
1716 Server(&'static str), // Uses a special name, and the user settings
1717}
1718
1719impl SettingsUiFile {
1720 fn setting_type(&self) -> &'static str {
1721 match self {
1722 SettingsUiFile::User => "User",
1723 SettingsUiFile::Project(_) => "Project",
1724 SettingsUiFile::Server(_) => "Server",
1725 }
1726 }
1727
1728 fn is_server(&self) -> bool {
1729 matches!(self, SettingsUiFile::Server(_))
1730 }
1731
1732 fn worktree_id(&self) -> Option<WorktreeId> {
1733 match self {
1734 SettingsUiFile::User => None,
1735 SettingsUiFile::Project((worktree_id, _)) => Some(*worktree_id),
1736 SettingsUiFile::Server(_) => None,
1737 }
1738 }
1739
1740 fn from_settings(file: settings::SettingsFile) -> Option<Self> {
1741 Some(match file {
1742 settings::SettingsFile::User => SettingsUiFile::User,
1743 settings::SettingsFile::Project(location) => SettingsUiFile::Project(location),
1744 settings::SettingsFile::Server => SettingsUiFile::Server("todo: server name"),
1745 settings::SettingsFile::Default => return None,
1746 settings::SettingsFile::Global => return None,
1747 })
1748 }
1749
1750 fn to_settings(&self) -> settings::SettingsFile {
1751 match self {
1752 SettingsUiFile::User => settings::SettingsFile::User,
1753 SettingsUiFile::Project(location) => settings::SettingsFile::Project(location.clone()),
1754 SettingsUiFile::Server(_) => settings::SettingsFile::Server,
1755 }
1756 }
1757
1758 fn mask(&self) -> FileMask {
1759 match self {
1760 SettingsUiFile::User => USER,
1761 SettingsUiFile::Project(_) => PROJECT,
1762 SettingsUiFile::Server(_) => SERVER,
1763 }
1764 }
1765}
1766
1767impl SettingsWindow {
1768 fn new(
1769 original_window: Option<WindowHandle<MultiWorkspace>>,
1770 window: &mut Window,
1771 cx: &mut Context<Self>,
1772 ) -> Self {
1773 let font_family_cache = theme::FontFamilyCache::global(cx);
1774
1775 cx.spawn(async move |this, cx| {
1776 font_family_cache.prefetch(cx).await;
1777 this.update(cx, |_, cx| {
1778 cx.notify();
1779 })
1780 })
1781 .detach();
1782
1783 let current_file = SettingsUiFile::User;
1784 let search_bar = cx.new(|cx| {
1785 let mut editor = Editor::single_line(window, cx);
1786 editor.set_placeholder_text("Search settings…", window, cx);
1787 editor
1788 });
1789 cx.subscribe(&search_bar, |this, _, event: &EditorEvent, cx| {
1790 let EditorEvent::Edited { transaction_id: _ } = event else {
1791 return;
1792 };
1793
1794 if this.opening_link {
1795 this.opening_link = false;
1796 return;
1797 }
1798 this.update_matches(cx);
1799 })
1800 .detach();
1801
1802 let mut ui_font_size = ThemeSettings::get_global(cx).ui_font_size(cx);
1803 cx.observe_global_in::<SettingsStore>(window, move |this, window, cx| {
1804 this.fetch_files(window, cx);
1805 // omega#81. Adding, removing, or re-versioning an external agent is
1806 // a settings change, and each of those changes what the External
1807 // Agents page must say about pins and provenance.
1808 this.refresh_harness_maintenance(cx);
1809
1810 // Whenever settings are changed, it's possible that the changed
1811 // settings affects the rendering of the `SettingsWindow`, like is
1812 // the case with `ui_font_size`. When that happens, we need to
1813 // instruct the `ListState` to re-measure the list items, as the
1814 // list item heights may have changed depending on the new font
1815 // size.
1816 let new_ui_font_size = ThemeSettings::get_global(cx).ui_font_size(cx);
1817 if new_ui_font_size != ui_font_size {
1818 this.list_state.remeasure();
1819 ui_font_size = new_ui_font_size;
1820 }
1821
1822 cx.notify();
1823 })
1824 .detach();
1825
1826 use feature_flags::FeatureFlagAppExt as _;
1827 let mut last_is_staff = cx.is_staff();
1828 cx.observe_global_in::<feature_flags::FeatureFlagStore>(window, move |this, window, cx| {
1829 let is_staff = cx.is_staff();
1830 if is_staff != last_is_staff {
1831 last_is_staff = is_staff;
1832 this.rebuild_pages(window, cx);
1833 }
1834 })
1835 .detach();
1836
1837 cx.observe_global_in::<SkillIndex>(window, |this, _window, cx| {
1838 if let Some(skill_index) = cx.try_global::<SkillIndex>() {
1839 this.hidden_deleted_skill_directory_paths
1840 .retain(|directory_path| {
1841 skill_index
1842 .global_skills
1843 .iter()
1844 .chain(
1845 skill_index
1846 .project_skills
1847 .iter()
1848 .flat_map(|group| group.skills.iter()),
1849 )
1850 .any(|skill| skill.directory_path.as_path() == directory_path.as_path())
1851 });
1852 } else {
1853 this.hidden_deleted_skill_directory_paths.clear();
1854 }
1855 cx.notify();
1856 })
1857 .detach();
1858
1859 let language_model_registry = language_model::LanguageModelRegistry::global(cx);
1860 cx.subscribe(&language_model_registry, |_, _, _event, cx| {
1861 cx.notify();
1862 })
1863 .detach();
1864
1865 cx.on_window_closed(|cx, _window_id| {
1866 if let Some(existing_window) = cx
1867 .windows()
1868 .into_iter()
1869 .find_map(|window| window.downcast::<SettingsWindow>())
1870 && cx.windows().len() == 1
1871 {
1872 cx.update_window(*existing_window, |_, window, _| {
1873 window.remove_window();
1874 })
1875 .ok();
1876
1877 telemetry::event!("Settings Closed")
1878 }
1879 })
1880 .detach();
1881
1882 let app_state = AppState::global(cx);
1883 let workspaces: Vec<Entity<Workspace>> = app_state
1884 .workspace_store
1885 .read(cx)
1886 .workspaces()
1887 .filter_map(|weak| weak.upgrade())
1888 .collect();
1889
1890 for workspace in workspaces {
1891 let project = workspace.read(cx).project().clone();
1892 cx.observe_release_in(&project, window, |this, _, window, cx| {
1893 this.fetch_files(window, cx)
1894 })
1895 .detach();
1896 cx.subscribe_in(&project, window, Self::handle_project_event)
1897 .detach();
1898 cx.observe_release_in(&workspace, window, |this, _, window, cx| {
1899 this.fetch_files(window, cx)
1900 })
1901 .detach();
1902 }
1903
1904 let this_weak = cx.weak_entity();
1905 cx.observe_new::<Project>({
1906 let this_weak = this_weak.clone();
1907
1908 move |_, window, cx| {
1909 let project = cx.entity();
1910 let Some(window) = window else {
1911 return;
1912 };
1913
1914 this_weak
1915 .update(cx, |_, cx| {
1916 cx.defer_in(window, |settings_window, window, cx| {
1917 settings_window.fetch_files(window, cx)
1918 });
1919 cx.observe_release_in(&project, window, |_, _, window, cx| {
1920 cx.defer_in(window, |this, window, cx| this.fetch_files(window, cx));
1921 })
1922 .detach();
1923
1924 cx.subscribe_in(&project, window, Self::handle_project_event)
1925 .detach();
1926 })
1927 .ok();
1928 }
1929 })
1930 .detach();
1931
1932 let handle = window.window_handle();
1933 cx.observe_new::<Workspace>(move |workspace, _, cx| {
1934 let project = workspace.project().clone();
1935 let this_weak = this_weak.clone();
1936
1937 // We defer on the settings window (via `handle`) rather than using
1938 // the workspace's window from observe_new. When window.defer() runs
1939 // its callback, it calls handle.update() which temporarily removes
1940 // that window from cx.windows. If we deferred on the workspace's
1941 // window, then when fetch_files() tries to read ALL workspaces from
1942 // the store (including the newly created one), it would fail with
1943 // "window not found" because that workspace's window would be
1944 // temporarily removed from cx.windows for the duration of our callback.
1945 handle
1946 .update(cx, move |_, window, cx| {
1947 window.defer(cx, move |window, cx| {
1948 this_weak
1949 .update(cx, |this, cx| {
1950 this.fetch_files(window, cx);
1951 cx.observe_release_in(&project, window, |this, _, window, cx| {
1952 this.fetch_files(window, cx)
1953 })
1954 .detach();
1955 })
1956 .ok();
1957 });
1958 })
1959 .ok();
1960 })
1961 .detach();
1962
1963 let title_bar = if !cfg!(target_os = "macos") {
1964 Some(cx.new(|cx| PlatformTitleBar::new("settings-title-bar", cx)))
1965 } else {
1966 None
1967 };
1968
1969 let list_state = gpui::ListState::new(0, gpui::ListAlignment::Top, px(0.0)).measure_all();
1970 list_state.set_scroll_handler(|_, _, _| {});
1971
1972 let mut this = Self {
1973 title_bar,
1974 original_window,
1975
1976 worktree_root_dirs: HashMap::default(),
1977 files: vec![],
1978
1979 current_file: current_file,
1980 project_setting_file_buffers: HashMap::default(),
1981 pages: vec![],
1982 sub_page_stack: vec![],
1983 opening_link: false,
1984 navbar_entries: vec![],
1985 navbar_entry: 0,
1986 navbar_scroll_handle: UniformListScrollHandle::default(),
1987 search_bar,
1988 search_task: None,
1989 filter_table: vec![],
1990 has_query: false,
1991 content_handles: vec![],
1992 focus_handle: cx.focus_handle(),
1993 navbar_focus_handle: NonFocusableHandle::new(
1994 NAVBAR_CONTAINER_TAB_INDEX,
1995 false,
1996 window,
1997 cx,
1998 ),
1999 navbar_focus_subscriptions: vec![],
2000 content_focus_handle: NonFocusableHandle::new(
2001 CONTENT_CONTAINER_TAB_INDEX,
2002 false,
2003 window,
2004 cx,
2005 ),
2006 files_focus_handle: cx
2007 .focus_handle()
2008 .tab_index(HEADER_CONTAINER_TAB_INDEX)
2009 .tab_stop(false),
2010 search_index: None,
2011 shown_errors: HashSet::default(),
2012 hidden_deleted_skill_directory_paths: HashSet::default(),
2013 regex_validation_error: None,
2014 sandbox_host_validation_error: None,
2015 list_state,
2016 last_copied_link_path: None,
2017 provider_configuration_views: HashMap::default(),
2018 configuring_provider: None,
2019 last_copied_skill_directory_path: None,
2020 llm_provider_form: None,
2021 llm_provider_add_focus_handle: cx.focus_handle(),
2022 mcp_server_form: None,
2023 mcp_add_server_focus_handle: cx.focus_handle(),
2024 custom_agent_form: None,
2025 external_agent_add_focus_handle: cx.focus_handle(),
2026 harness_maintenance: Default::default(),
2027 harness_maintenance_task: None,
2028 harness_maintenance_error: None,
2029 skill_creator_page: None,
2030 };
2031
2032 this.fetch_files(window, cx);
2033 this.build_ui(window, cx);
2034 this.build_search_index();
2035 this.refresh_harness_maintenance(cx);
2036
2037 this.search_bar.update(cx, |editor, cx| {
2038 editor.focus_handle(cx).focus(window, cx);
2039 });
2040
2041 this
2042 }
2043
2044 /// Recompute what the External Agents page shows about pins and
2045 /// provenance. omega#81, `OMEGA-DELTA-0033`.
2046 ///
2047 /// Reads the pin ledger, measures each installed tree, and reads the
2048 /// receipt log — the same three inputs the launch gate reads, through the
2049 /// same function. The page renders the result and decides nothing.
2050 ///
2051 /// Off the render path deliberately: hashing an installed tree per frame
2052 /// would make opening a settings page cost megabytes of disk reads a
2053 /// second.
2054 pub(crate) fn refresh_harness_maintenance(&mut self, cx: &mut Context<Self>) {
2055 let Some(store) = pages::external_agents_page::agent_server_store(self, cx) else {
2056 self.harness_maintenance.clear();
2057 self.harness_maintenance_task = None;
2058 return;
2059 };
2060 let targets = {
2061 let store = store.read(cx);
2062 store
2063 .external_agents()
2064 .cloned()
2065 .collect::<Vec<_>>()
2066 .into_iter()
2067 .filter_map(|id| store.maintenance_target(&id).map(|target| (id, target)))
2068 .collect::<Vec<_>>()
2069 };
2070 let fs = <dyn fs::Fs>::global(cx);
2071
2072 self.harness_maintenance_task = Some(cx.spawn(async move |this, cx| {
2073 let mut states = ::collections::HashMap::default();
2074 for (id, target) in targets {
2075 let state = project::harness_maintenance::read_front_door_state(
2076 fs.as_ref(),
2077 target.harness_id.as_ref(),
2078 target.version.as_ref(),
2079 target.distribution,
2080 target.installed_dir.as_deref(),
2081 )
2082 .await;
2083 states.insert(id, state);
2084 }
2085 this.update(cx, |this, cx| {
2086 this.harness_maintenance = states;
2087 cx.notify();
2088 })
2089 .ok();
2090 }));
2091 }
2092
2093 /// Recompute the maintenance rows and wait for nothing.
2094 ///
2095 /// The visual test needs the row to be populated at the moment it takes the
2096 /// picture; production repopulates it on window open, on a settings change,
2097 /// and after an owner action, none of which a screenshot can wait for.
2098 #[cfg(any(test, feature = "test-support"))]
2099 pub fn refresh_harness_maintenance_for_test(&mut self, cx: &mut Context<Self>) {
2100 self.refresh_harness_maintenance(cx);
2101 }
2102
2103 fn harness_maintenance_target(
2104 &self,
2105 id: &project::AgentId,
2106 cx: &App,
2107 ) -> Option<project::agent_server_store::HarnessMaintenanceTarget> {
2108 let store = pages::external_agents_page::agent_server_store(self, cx)?;
2109 store.read(cx).maintenance_target(id)
2110 }
2111
2112 /// Freeze one harness at the bytes Omega measures now. omega#81.
2113 ///
2114 /// The whole write happens in `project::harness_maintenance`, through the
2115 /// same gate the launch path runs, so a tree the gate would refuse cannot be
2116 /// pinned from here. This method routes a click and shows what came back.
2117 pub(crate) fn pin_harness(&mut self, id: &project::AgentId, cx: &mut Context<Self>) {
2118 let Some(target) = self.harness_maintenance_target(id, cx) else {
2119 return;
2120 };
2121 let fs = <dyn fs::Fs>::global(cx);
2122 let id = id.clone();
2123 self.harness_maintenance_error = None;
2124 cx.spawn(async move |this, cx| {
2125 let result = match target.installed_dir.as_deref() {
2126 Some(dir) => {
2127 project::harness_maintenance::pin_installed_harness(
2128 fs,
2129 target.harness_id.as_ref(),
2130 target.version.as_ref(),
2131 dir,
2132 project::harness_maintenance::now_ms(),
2133 )
2134 .await
2135 }
2136 None => Err(anyhow::anyhow!(
2137 "Omega owns no installed directory for this agent, so there is nothing to pin."
2138 )),
2139 };
2140 this.update(cx, |this, cx| {
2141 this.set_harness_maintenance_result(&id, result, cx);
2142 })
2143 .ok();
2144 })
2145 .detach();
2146 }
2147
2148 /// Unfreeze one harness. omega#81.
2149 pub(crate) fn unpin_harness(&mut self, id: &project::AgentId, cx: &mut Context<Self>) {
2150 let Some(target) = self.harness_maintenance_target(id, cx) else {
2151 return;
2152 };
2153 let fs = <dyn fs::Fs>::global(cx);
2154 let id = id.clone();
2155 self.harness_maintenance_error = None;
2156 cx.spawn(async move |this, cx| {
2157 let result =
2158 project::harness_maintenance::unpin_harness(fs, target.harness_id.as_ref()).await;
2159 this.update(cx, |this, cx| {
2160 this.set_harness_maintenance_result(&id, result, cx);
2161 })
2162 .ok();
2163 })
2164 .detach();
2165 }
2166
2167 /// Re-measure one harness's installed tree on request, writing a receipt
2168 /// under [`omega_harness::MaintenanceAction::ReprobeCapability`]. omega#81.
2169 pub(crate) fn reprobe_harness(&mut self, id: &project::AgentId, cx: &mut Context<Self>) {
2170 let Some(target) = self.harness_maintenance_target(id, cx) else {
2171 return;
2172 };
2173 let fs = <dyn fs::Fs>::global(cx);
2174 let id = id.clone();
2175 self.harness_maintenance_error = None;
2176 cx.spawn(async move |this, cx| {
2177 let result = match target.installed_dir.as_deref() {
2178 Some(dir) => project::harness_maintenance::reprobe_installed_harness(
2179 fs,
2180 target.harness_id.as_ref(),
2181 target.version.as_ref(),
2182 dir,
2183 project::harness_maintenance::now_ms(),
2184 )
2185 .await
2186 .map(|_| ()),
2187 None => Err(anyhow::anyhow!(
2188 "Omega owns no installed directory for this agent, so there is nothing to \
2189 re-check."
2190 )),
2191 };
2192 this.update(cx, |this, cx| {
2193 this.set_harness_maintenance_result(&id, result, cx);
2194 })
2195 .ok();
2196 })
2197 .detach();
2198 }
2199
2200 /// Show what a maintenance control did, and re-read the state either way.
2201 ///
2202 /// A failed pin write that left the row unchanged and said nothing would be
2203 /// the same defect the receipt contract exists to prevent, one layer up.
2204 fn set_harness_maintenance_result(
2205 &mut self,
2206 id: &project::AgentId,
2207 result: anyhow::Result<()>,
2208 cx: &mut Context<Self>,
2209 ) {
2210 self.harness_maintenance_error = result
2211 .err()
2212 .map(|error| (id.clone(), SharedString::from(error.to_string())));
2213 self.refresh_harness_maintenance(cx);
2214 cx.notify();
2215 }
2216
2217 fn handle_project_event(
2218 &mut self,
2219 _: &Entity<Project>,
2220 event: &project::Event,
2221 window: &mut Window,
2222 cx: &mut Context<SettingsWindow>,
2223 ) {
2224 match event {
2225 project::Event::WorktreeRemoved(_) | project::Event::WorktreeAdded(_) => {
2226 cx.defer_in(window, |this, window, cx| {
2227 this.fetch_files(window, cx);
2228 });
2229 }
2230 _ => {}
2231 }
2232 }
2233
2234 fn toggle_navbar_entry(&mut self, nav_entry_index: usize) {
2235 // We can only toggle root entries
2236 if !self.navbar_entries[nav_entry_index].is_root {
2237 return;
2238 }
2239
2240 let expanded = &mut self.navbar_entries[nav_entry_index].expanded;
2241 *expanded = !*expanded;
2242 self.navbar_entry = nav_entry_index;
2243 self.reset_list_state();
2244 }
2245
2246 fn toggle_and_focus_navbar_entry(
2247 &mut self,
2248 nav_entry_index: usize,
2249 window: &mut Window,
2250 cx: &mut Context<Self>,
2251 ) {
2252 self.toggle_navbar_entry(nav_entry_index);
2253 window.focus(&self.navbar_entries[nav_entry_index].focus_handle, cx);
2254 cx.notify();
2255 }
2256
2257 fn toggle_navbar_entry_on_double_click(
2258 &mut self,
2259 nav_entry_index: usize,
2260 event: &gpui::ClickEvent,
2261 window: &mut Window,
2262 cx: &mut Context<Self>,
2263 ) -> bool {
2264 let Some(entry) = self.navbar_entries.get(nav_entry_index) else {
2265 return false;
2266 };
2267 if !entry.is_root || event.click_count() != 2 {
2268 return false;
2269 }
2270
2271 self.toggle_and_focus_navbar_entry(nav_entry_index, window, cx);
2272 true
2273 }
2274
2275 fn build_navbar(&mut self, cx: &App) {
2276 let mut navbar_entries = Vec::new();
2277
2278 for (page_index, page) in self.pages.iter().enumerate() {
2279 navbar_entries.push(NavBarEntry {
2280 title: page.title,
2281 is_root: true,
2282 expanded: false,
2283 page_index,
2284 item_index: None,
2285 focus_handle: cx.focus_handle().tab_index(0).tab_stop(true),
2286 });
2287
2288 for (item_index, item) in page.items.iter().enumerate() {
2289 let SettingsPageItem::SectionHeader(title) = item else {
2290 continue;
2291 };
2292 navbar_entries.push(NavBarEntry {
2293 title,
2294 is_root: false,
2295 expanded: false,
2296 page_index,
2297 item_index: Some(item_index),
2298 focus_handle: cx.focus_handle().tab_index(0).tab_stop(true),
2299 });
2300 }
2301 }
2302
2303 self.navbar_entries = navbar_entries;
2304 }
2305
2306 fn setup_navbar_focus_subscriptions(
2307 &mut self,
2308 window: &mut Window,
2309 cx: &mut Context<SettingsWindow>,
2310 ) {
2311 let mut focus_subscriptions = Vec::new();
2312
2313 for entry_index in 0..self.navbar_entries.len() {
2314 let focus_handle = self.navbar_entries[entry_index].focus_handle.clone();
2315
2316 let subscription = cx.on_focus(
2317 &focus_handle,
2318 window,
2319 move |this: &mut SettingsWindow,
2320 window: &mut Window,
2321 cx: &mut Context<SettingsWindow>| {
2322 if this.sub_page_stack.is_empty() {
2323 this.open_and_scroll_to_navbar_entry(entry_index, None, false, window, cx);
2324 }
2325 },
2326 );
2327 focus_subscriptions.push(subscription);
2328 }
2329 self.navbar_focus_subscriptions = focus_subscriptions;
2330 }
2331
2332 fn visible_navbar_entries(&self) -> impl Iterator<Item = (usize, &NavBarEntry)> {
2333 let mut index = 0;
2334 let entries = &self.navbar_entries;
2335 let search_matches = &self.filter_table;
2336 let has_query = self.has_query;
2337 std::iter::from_fn(move || {
2338 while index < entries.len() {
2339 let entry = &entries[index];
2340 let included_in_search = if let Some(item_index) = entry.item_index {
2341 search_matches[entry.page_index][item_index]
2342 } else {
2343 search_matches[entry.page_index].iter().any(|b| *b)
2344 || search_matches[entry.page_index].is_empty()
2345 };
2346 if included_in_search {
2347 break;
2348 }
2349 index += 1;
2350 }
2351 if index >= self.navbar_entries.len() {
2352 return None;
2353 }
2354 let entry = &entries[index];
2355 let entry_index = index;
2356
2357 index += 1;
2358 if entry.is_root && !entry.expanded && !has_query {
2359 while index < entries.len() {
2360 if entries[index].is_root {
2361 break;
2362 }
2363 index += 1;
2364 }
2365 }
2366
2367 return Some((entry_index, entry));
2368 })
2369 }
2370
2371 fn filter_matches_to_file(&mut self) {
2372 let current_file = self.current_file.mask();
2373 for (page, page_filter) in std::iter::zip(&self.pages, &mut self.filter_table) {
2374 let mut header_index = 0;
2375 let mut any_found_since_last_header = true;
2376
2377 for (index, item) in page.items.iter().enumerate() {
2378 match item {
2379 SettingsPageItem::SectionHeader(_) => {
2380 if !any_found_since_last_header {
2381 page_filter[header_index] = false;
2382 }
2383 header_index = index;
2384 any_found_since_last_header = false;
2385 }
2386 SettingsPageItem::SettingItem(SettingItem { files, .. })
2387 | SettingsPageItem::SubPageLink(SubPageLink { files, .. })
2388 | SettingsPageItem::DynamicItem(DynamicItem {
2389 discriminant: SettingItem { files, .. },
2390 ..
2391 }) => {
2392 if !files.contains(current_file) {
2393 page_filter[index] = false;
2394 } else {
2395 any_found_since_last_header = true;
2396 }
2397 }
2398 SettingsPageItem::ActionLink(ActionLink { files, .. }) => {
2399 if !files.contains(current_file) {
2400 page_filter[index] = false;
2401 } else {
2402 any_found_since_last_header = true;
2403 }
2404 }
2405 }
2406 }
2407 if let Some(last_header) = page_filter.get_mut(header_index)
2408 && !any_found_since_last_header
2409 {
2410 *last_header = false;
2411 }
2412 }
2413 }
2414
2415 fn filter_by_json_path(&self, query: &str) -> Vec<usize> {
2416 let Some(path) = query.strip_prefix('#') else {
2417 return vec![];
2418 };
2419 let Some(search_index) = self.search_index.as_ref() else {
2420 return vec![];
2421 };
2422 let mut indices = vec![];
2423 for (index, SearchKeyLUTEntry { json_path, .. }) in search_index.key_lut.iter().enumerate()
2424 {
2425 let Some(json_path) = json_path else {
2426 continue;
2427 };
2428
2429 if let Some(post) = json_path.strip_prefix(path)
2430 && (post.is_empty() || post.starts_with('.'))
2431 {
2432 indices.push(index);
2433 }
2434 }
2435 indices
2436 }
2437
2438 fn apply_match_indices(&mut self, match_indices: impl Iterator<Item = usize>, query: &str) {
2439 let Some(search_index) = self.search_index.as_ref() else {
2440 return;
2441 };
2442
2443 for page in &mut self.filter_table {
2444 page.fill(false);
2445 }
2446
2447 for match_index in match_indices {
2448 let SearchKeyLUTEntry {
2449 page_index,
2450 header_index,
2451 item_index,
2452 ..
2453 } = search_index.key_lut[match_index];
2454 let page = &mut self.filter_table[page_index];
2455 page[header_index] = true;
2456 page[item_index] = true;
2457 }
2458 self.has_query = true;
2459 self.filter_matches_to_file();
2460 let query_lower = query.to_lowercase();
2461 let query_words: Vec<&str> = query_lower.split_whitespace().collect();
2462 self.open_best_matching_nav_page(&query_words);
2463 self.reset_list_state();
2464 self.scroll_content_to_best_match(&query_words);
2465 }
2466
2467 fn update_matches(&mut self, cx: &mut Context<SettingsWindow>) {
2468 self.search_task.take();
2469 let query = self.search_bar.read(cx).text(cx);
2470 if query.is_empty() || self.search_index.is_none() {
2471 for page in &mut self.filter_table {
2472 page.fill(true);
2473 }
2474 self.has_query = false;
2475 self.filter_matches_to_file();
2476 self.reset_list_state();
2477 cx.notify();
2478 return;
2479 }
2480
2481 let is_json_link_query = query.starts_with("#");
2482 if is_json_link_query {
2483 let indices = self.filter_by_json_path(&query);
2484 if !indices.is_empty() {
2485 self.apply_match_indices(indices.into_iter(), &query);
2486 cx.notify();
2487 return;
2488 }
2489 }
2490
2491 let search_index = self.search_index.as_ref().unwrap().clone();
2492
2493 self.search_task = Some(cx.spawn(async move |this, cx| {
2494 let exact_match_task = cx.background_spawn({
2495 let search_index = search_index.clone();
2496 let query = query.clone();
2497 async move {
2498 let query_lower = query.to_lowercase();
2499 let query_words: Vec<&str> = query_lower.split_whitespace().collect();
2500 if query_words.is_empty() {
2501 return Vec::new();
2502 }
2503 search_index
2504 .documents
2505 .iter()
2506 .filter(|doc| {
2507 query_words.iter().all(|query_word| {
2508 doc.words
2509 .iter()
2510 .any(|doc_word| doc_word.starts_with(query_word))
2511 })
2512 })
2513 .map(|doc| doc.id)
2514 .collect::<Vec<usize>>()
2515 }
2516 });
2517 let cancel_flag = std::sync::atomic::AtomicBool::new(false);
2518 let fuzzy_search_task = fuzzy::match_strings(
2519 search_index.fuzzy_match_candidates.as_slice(),
2520 &query,
2521 false,
2522 true,
2523 search_index.fuzzy_match_candidates.len(),
2524 &cancel_flag,
2525 cx.background_executor().clone(),
2526 );
2527
2528 let fuzzy_matches = fuzzy_search_task.await;
2529 let exact_matches = exact_match_task.await;
2530
2531 this.update(cx, |this, cx| {
2532 let exact_indices = exact_matches.into_iter();
2533 let fuzzy_indices = fuzzy_matches
2534 .into_iter()
2535 .take_while(|fuzzy_match| fuzzy_match.score >= 0.5)
2536 .map(|fuzzy_match| fuzzy_match.candidate_id);
2537 let merged_indices = exact_indices.chain(fuzzy_indices);
2538
2539 this.apply_match_indices(merged_indices, &query);
2540 cx.notify();
2541 })
2542 .ok();
2543
2544 cx.background_executor().timer(Duration::from_secs(1)).await;
2545 telemetry::event!("Settings Searched", query = query)
2546 }));
2547 }
2548
2549 fn build_filter_table(&mut self) {
2550 self.filter_table = self
2551 .pages
2552 .iter()
2553 .map(|page| vec![true; page.items.len()])
2554 .collect::<Vec<_>>();
2555 }
2556
2557 fn build_search_index(&mut self) {
2558 fn split_into_words(parts: &[&str]) -> Vec<String> {
2559 parts
2560 .iter()
2561 .flat_map(|s| {
2562 s.split(|c: char| !c.is_alphanumeric())
2563 .filter(|w| !w.is_empty())
2564 .map(|w| w.to_lowercase())
2565 })
2566 .collect()
2567 }
2568
2569 let mut key_lut: Vec<SearchKeyLUTEntry> = vec![];
2570 let mut documents: Vec<SearchDocument> = Vec::default();
2571 let mut fuzzy_match_candidates = Vec::default();
2572
2573 fn push_candidates(
2574 fuzzy_match_candidates: &mut Vec<StringMatchCandidate>,
2575 key_index: usize,
2576 input: &str,
2577 ) {
2578 for word in input.split_ascii_whitespace() {
2579 fuzzy_match_candidates.push(StringMatchCandidate::new(key_index, word));
2580 }
2581 }
2582
2583 // PERF: We are currently searching all items even in project files
2584 // where many settings are filtered out, using the logic in filter_matches_to_file
2585 // we could only search relevant items based on the current file
2586 for (page_index, page) in self.pages.iter().enumerate() {
2587 let mut header_index = 0;
2588 let mut header_str = "";
2589 for (item_index, item) in page.items.iter().enumerate() {
2590 let key_index = key_lut.len();
2591 let mut json_path = None;
2592 match item {
2593 SettingsPageItem::DynamicItem(DynamicItem {
2594 discriminant: item, ..
2595 })
2596 | SettingsPageItem::SettingItem(item) => {
2597 json_path = item
2598 .field
2599 .json_path()
2600 .map(|path| path.trim_end_matches('$'));
2601 documents.push(SearchDocument {
2602 id: key_index,
2603 words: split_into_words(&[
2604 page.title,
2605 header_str,
2606 item.title,
2607 item.description,
2608 ]),
2609 });
2610 push_candidates(&mut fuzzy_match_candidates, key_index, item.title);
2611 push_candidates(&mut fuzzy_match_candidates, key_index, item.description);
2612 }
2613 SettingsPageItem::SectionHeader(header) => {
2614 documents.push(SearchDocument {
2615 id: key_index,
2616 words: split_into_words(&[header]),
2617 });
2618 push_candidates(&mut fuzzy_match_candidates, key_index, header);
2619 header_index = item_index;
2620 header_str = *header;
2621 }
2622 SettingsPageItem::SubPageLink(sub_page_link) => {
2623 json_path = sub_page_link.json_path;
2624 let mut parts = vec![page.title, header_str, sub_page_link.title.as_ref()];
2625 parts.extend(sub_page_link.search_aliases);
2626 documents.push(SearchDocument {
2627 id: key_index,
2628 words: split_into_words(&parts),
2629 });
2630 push_candidates(
2631 &mut fuzzy_match_candidates,
2632 key_index,
2633 sub_page_link.title.as_ref(),
2634 );
2635 for alias in sub_page_link.search_aliases {
2636 push_candidates(&mut fuzzy_match_candidates, key_index, alias);
2637 }
2638 }
2639 SettingsPageItem::ActionLink(action_link) => {
2640 documents.push(SearchDocument {
2641 id: key_index,
2642 words: split_into_words(&[
2643 page.title,
2644 header_str,
2645 action_link.title.as_ref(),
2646 ]),
2647 });
2648 push_candidates(
2649 &mut fuzzy_match_candidates,
2650 key_index,
2651 action_link.title.as_ref(),
2652 );
2653 }
2654 }
2655 push_candidates(&mut fuzzy_match_candidates, key_index, page.title);
2656 push_candidates(&mut fuzzy_match_candidates, key_index, header_str);
2657
2658 key_lut.push(SearchKeyLUTEntry {
2659 page_index,
2660 header_index,
2661 item_index,
2662 json_path,
2663 });
2664 }
2665 }
2666 self.search_index = Some(Arc::new(SearchIndex {
2667 documents,
2668 key_lut,
2669 fuzzy_match_candidates,
2670 }));
2671 }
2672
2673 fn build_content_handles(&mut self, window: &mut Window, cx: &mut Context<SettingsWindow>) {
2674 self.content_handles = self
2675 .pages
2676 .iter()
2677 .map(|page| {
2678 std::iter::repeat_with(|| NonFocusableHandle::new(0, false, window, cx))
2679 .take(page.items.len())
2680 .collect()
2681 })
2682 .collect::<Vec<_>>();
2683 }
2684
2685 fn reset_list_state(&mut self) {
2686 let mut visible_items_count = self.visible_page_items().count();
2687
2688 if visible_items_count > 0 {
2689 // show page title if page is non empty
2690 visible_items_count += 1;
2691 }
2692
2693 self.list_state.reset(visible_items_count);
2694 }
2695
2696 fn build_ui(&mut self, window: &mut Window, cx: &mut Context<SettingsWindow>) {
2697 if self.pages.is_empty() {
2698 self.pages = page_data::settings_data(cx);
2699 self.build_navbar(cx);
2700 self.setup_navbar_focus_subscriptions(window, cx);
2701 self.build_content_handles(window, cx);
2702 }
2703 self.sub_page_stack.clear();
2704 // PERF: doesn't have to be rebuilt, can just be filled with true. pages is constant once it is built
2705 self.build_filter_table();
2706 self.reset_list_state();
2707 self.update_matches(cx);
2708
2709 cx.notify();
2710 }
2711
2712 fn rebuild_pages(&mut self, window: &mut Window, cx: &mut Context<SettingsWindow>) {
2713 self.pages.clear();
2714 self.navbar_entries.clear();
2715 self.navbar_focus_subscriptions.clear();
2716 self.content_handles.clear();
2717 self.build_ui(window, cx);
2718 self.build_search_index();
2719 }
2720
2721 #[track_caller]
2722 fn fetch_files(&mut self, window: &mut Window, cx: &mut Context<SettingsWindow>) {
2723 self.worktree_root_dirs.clear();
2724 let prev_files = self.files.clone();
2725 let settings_store = cx.global::<SettingsStore>();
2726 let mut ui_files = vec![];
2727 let mut all_files = settings_store.get_all_files();
2728 if !all_files.contains(&settings::SettingsFile::User) {
2729 all_files.push(settings::SettingsFile::User);
2730 }
2731 for file in all_files {
2732 let Some(settings_ui_file) = SettingsUiFile::from_settings(file) else {
2733 continue;
2734 };
2735 if settings_ui_file.is_server() {
2736 continue;
2737 }
2738
2739 if let Some(worktree_id) = settings_ui_file.worktree_id() {
2740 let directory_name = all_projects(self.original_window.as_ref(), cx)
2741 .find_map(|project| project.read(cx).worktree_for_id(worktree_id, cx))
2742 .map(|worktree| worktree.read(cx).root_name());
2743
2744 let Some(directory_name) = directory_name else {
2745 log::error!(
2746 "No directory name found for settings file at worktree ID: {}",
2747 worktree_id
2748 );
2749 continue;
2750 };
2751
2752 self.worktree_root_dirs
2753 .insert(worktree_id, directory_name.as_unix_str().to_string());
2754 }
2755
2756 let focus_handle = prev_files
2757 .iter()
2758 .find_map(|(prev_file, handle)| {
2759 (prev_file == &settings_ui_file).then(|| handle.clone())
2760 })
2761 .unwrap_or_else(|| cx.focus_handle().tab_index(0).tab_stop(true));
2762 ui_files.push((settings_ui_file, focus_handle));
2763 }
2764
2765 ui_files.reverse();
2766
2767 if self.original_window.is_some() {
2768 let mut missing_worktrees = Vec::new();
2769
2770 for worktree in all_projects(self.original_window.as_ref(), cx)
2771 .flat_map(|project| project.read(cx).visible_worktrees(cx))
2772 .filter(|tree| !self.worktree_root_dirs.contains_key(&tree.read(cx).id()))
2773 {
2774 let worktree = worktree.read(cx);
2775 let worktree_id = worktree.id();
2776 let Some(directory_name) = worktree.root_dir().and_then(|file| {
2777 file.file_name()
2778 .map(|os_string| os_string.to_string_lossy().to_string())
2779 }) else {
2780 continue;
2781 };
2782
2783 missing_worktrees.push((worktree_id, directory_name.clone()));
2784 let path = RelPath::empty().to_owned().into_arc();
2785
2786 let settings_ui_file = SettingsUiFile::Project((worktree_id, path));
2787
2788 let focus_handle = prev_files
2789 .iter()
2790 .find_map(|(prev_file, handle)| {
2791 (prev_file == &settings_ui_file).then(|| handle.clone())
2792 })
2793 .unwrap_or_else(|| cx.focus_handle().tab_index(0).tab_stop(true));
2794
2795 ui_files.push((settings_ui_file, focus_handle));
2796 }
2797
2798 self.worktree_root_dirs.extend(missing_worktrees);
2799 }
2800
2801 self.files = ui_files;
2802 let current_file_still_exists = self
2803 .files
2804 .iter()
2805 .any(|(file, _)| file == &self.current_file);
2806 if !current_file_still_exists {
2807 self.change_file(0, window, cx);
2808 }
2809 }
2810
2811 fn open_navbar_entry_page(&mut self, navbar_entry: usize) {
2812 // Navigating to another page dismisses the transient "copied share
2813 // link" checkmark shown on a Skills page row.
2814 self.last_copied_skill_directory_path = None;
2815
2816 if !self.is_nav_entry_visible(navbar_entry) {
2817 self.open_first_nav_page();
2818 }
2819
2820 let is_new_page = self.navbar_entries[self.navbar_entry].page_index
2821 != self.navbar_entries[navbar_entry].page_index;
2822
2823 self.navbar_entry = navbar_entry;
2824
2825 // We only need to reset visible items when updating matches
2826 // and selecting a new page
2827 if is_new_page {
2828 self.reset_list_state();
2829 }
2830
2831 self.sub_page_stack.clear();
2832 }
2833
2834 fn open_best_matching_nav_page(&mut self, query_words: &[&str]) {
2835 let mut entries = self.visible_navbar_entries().peekable();
2836 let first_entry = entries.peek().map(|(index, _)| (0, *index));
2837 let best_match = entries
2838 .enumerate()
2839 .filter(|(_, (_, entry))| !entry.is_root)
2840 .map(|(logical_index, (index, entry))| {
2841 let title_lower = entry.title.to_lowercase();
2842 let matching_words = query_words
2843 .iter()
2844 .filter(|query_word| {
2845 title_lower
2846 .split_whitespace()
2847 .any(|title_word| title_word.starts_with(*query_word))
2848 })
2849 .count();
2850 (logical_index, index, matching_words)
2851 })
2852 .filter(|(_, _, count)| *count > 0)
2853 .max_by_key(|(_, _, count)| *count)
2854 .map(|(logical_index, index, _)| (logical_index, index));
2855 if let Some((logical_index, navbar_entry_index)) = best_match.or(first_entry) {
2856 self.open_navbar_entry_page(navbar_entry_index);
2857 self.navbar_scroll_handle
2858 .scroll_to_item(logical_index + 1, gpui::ScrollStrategy::Top);
2859 }
2860 }
2861
2862 fn scroll_content_to_best_match(&self, query_words: &[&str]) {
2863 let position = self
2864 .visible_page_items()
2865 .enumerate()
2866 .find(|(_, (_, item))| match item {
2867 SettingsPageItem::SectionHeader(title) => {
2868 let title_lower = title.to_lowercase();
2869 query_words.iter().all(|query_word| {
2870 title_lower
2871 .split_whitespace()
2872 .any(|title_word| title_word.starts_with(query_word))
2873 })
2874 }
2875 _ => false,
2876 })
2877 .map(|(position, _)| position);
2878 if let Some(position) = position {
2879 self.list_state.scroll_to(gpui::ListOffset {
2880 item_ix: position + 1,
2881 offset_in_item: px(0.),
2882 });
2883 }
2884 }
2885
2886 fn open_first_nav_page(&mut self) {
2887 let Some(first_navbar_entry_index) = self.visible_navbar_entries().next().map(|e| e.0)
2888 else {
2889 return;
2890 };
2891 self.open_navbar_entry_page(first_navbar_entry_index);
2892 }
2893
2894 fn change_file(&mut self, ix: usize, window: &mut Window, cx: &mut Context<SettingsWindow>) {
2895 if ix >= self.files.len() {
2896 self.current_file = SettingsUiFile::User;
2897 self.build_ui(window, cx);
2898 return;
2899 }
2900
2901 if self.files[ix].0 == self.current_file {
2902 return;
2903 }
2904 self.current_file = self.files[ix].0.clone();
2905
2906 if let SettingsUiFile::Project((_, _)) = &self.current_file {
2907 telemetry::event!("Setting Project Clicked");
2908 }
2909
2910 self.build_ui(window, cx);
2911
2912 if self
2913 .visible_navbar_entries()
2914 .any(|(index, _)| index == self.navbar_entry)
2915 {
2916 self.open_and_scroll_to_navbar_entry(self.navbar_entry, None, true, window, cx);
2917 } else {
2918 self.open_first_nav_page();
2919 };
2920 }
2921
2922 /// Changes the current settings file like [`Self::change_file`], but keeps
2923 /// the currently open sub-page stack when every sub-page in it is
2924 /// available in the new file's scope (e.g. switching a Skills sub-page
2925 /// between the user scope and a project scope).
2926 fn change_file_in_sub_page(
2927 &mut self,
2928 ix: usize,
2929 window: &mut Window,
2930 cx: &mut Context<SettingsWindow>,
2931 ) {
2932 if ix >= self.files.len() || self.files[ix].0 == self.current_file {
2933 return;
2934 }
2935 self.current_file = self.files[ix].0.clone();
2936
2937 if let SettingsUiFile::Project((_, _)) = &self.current_file {
2938 telemetry::event!("Setting Project Clicked");
2939 }
2940
2941 self.last_copied_skill_directory_path = None;
2942
2943 let sub_page_stack = std::mem::take(&mut self.sub_page_stack);
2944 self.build_ui(window, cx);
2945
2946 let file_mask = self.current_file.mask();
2947 if let Some(first_sub_page) = sub_page_stack.first()
2948 && sub_page_stack
2949 .iter()
2950 .all(|sub_page| sub_page.link.files.contains(file_mask))
2951 {
2952 if !self.is_nav_entry_visible(self.navbar_entry) {
2953 // The previously selected page may be filtered out in the new
2954 // scope (e.g. after deep-linking into a sub-page). Re-anchor
2955 // the navbar to the page containing the open sub-page, which
2956 // is visible because its sub-page link supports this scope.
2957 let anchor_entry = self
2958 .pages
2959 .iter()
2960 .position(|page| {
2961 page.items.iter().any(|item| {
2962 matches!(item, SettingsPageItem::SubPageLink(link) if link == &first_sub_page.link)
2963 })
2964 })
2965 .and_then(|page_index| {
2966 self.navbar_entries
2967 .iter()
2968 .position(|entry| entry.is_root && entry.page_index == page_index)
2969 });
2970 if let Some(anchor_entry) = anchor_entry
2971 && self.is_nav_entry_visible(anchor_entry)
2972 {
2973 self.open_navbar_entry_page(anchor_entry);
2974 }
2975 }
2976 if self.is_nav_entry_visible(self.navbar_entry) {
2977 self.sub_page_stack = sub_page_stack;
2978 cx.notify();
2979 return;
2980 }
2981 }
2982
2983 if self.is_nav_entry_visible(self.navbar_entry) {
2984 self.open_and_scroll_to_navbar_entry(self.navbar_entry, None, true, window, cx);
2985 } else {
2986 self.open_first_nav_page();
2987 }
2988 }
2989
2990 fn render_files_header(
2991 &self,
2992 window: &mut Window,
2993 cx: &mut Context<SettingsWindow>,
2994 ) -> impl IntoElement {
2995 static OVERFLOW_LIMIT: usize = 1;
2996
2997 let file_button =
2998 |ix, file: &SettingsUiFile, focus_handle, cx: &mut Context<SettingsWindow>| {
2999 Button::new(
3000 ix,
3001 self.display_name(&file)
3002 .expect("Files should always have a name"),
3003 )
3004 .toggle_state(file == &self.current_file)
3005 .selected_style(ButtonStyle::Tinted(ui::TintColor::Accent))
3006 .track_focus(focus_handle)
3007 .on_click(cx.listener({
3008 let focus_handle = focus_handle.clone();
3009 move |this, _: &gpui::ClickEvent, window, cx| {
3010 this.change_file(ix, window, cx);
3011 focus_handle.focus(window, cx);
3012 }
3013 }))
3014 };
3015
3016 let this = cx.entity();
3017
3018 let selected_file_ix = self
3019 .files
3020 .iter()
3021 .enumerate()
3022 .skip(OVERFLOW_LIMIT)
3023 .find_map(|(ix, (file, _))| {
3024 if file == &self.current_file {
3025 Some(ix)
3026 } else {
3027 None
3028 }
3029 })
3030 .unwrap_or(OVERFLOW_LIMIT);
3031 let edit_in_json_id = SharedString::new(format!("edit-in-json-{}", selected_file_ix));
3032
3033 h_flex()
3034 .id("settings-ui-files-header")
3035 .role(Role::Group)
3036 .aria_label("Settings File")
3037 .w_full()
3038 .gap_1()
3039 .justify_between()
3040 .track_focus(&self.files_focus_handle)
3041 .tab_group()
3042 .tab_index(HEADER_GROUP_TAB_INDEX)
3043 .child(
3044 h_flex()
3045 .gap_1()
3046 .children(
3047 self.files.iter().enumerate().take(OVERFLOW_LIMIT).map(
3048 |(ix, (file, focus_handle))| file_button(ix, file, focus_handle, cx),
3049 ),
3050 )
3051 .when(self.files.len() > OVERFLOW_LIMIT, |div| {
3052 let (file, focus_handle) = &self.files[selected_file_ix];
3053
3054 div.child(file_button(selected_file_ix, file, focus_handle, cx))
3055 .when(self.files.len() > OVERFLOW_LIMIT + 1, |div| {
3056 div.child(
3057 DropdownMenu::new(
3058 "more-files",
3059 format!("+{}", self.files.len() - (OVERFLOW_LIMIT + 1)),
3060 ContextMenu::build(window, cx, move |mut menu, _, _| {
3061 for (mut ix, (file, focus_handle)) in self
3062 .files
3063 .iter()
3064 .enumerate()
3065 .skip(OVERFLOW_LIMIT + 1)
3066 {
3067 let (display_name, focus_handle) =
3068 if selected_file_ix == ix {
3069 ix = OVERFLOW_LIMIT;
3070 (
3071 self.display_name(&self.files[ix].0),
3072 self.files[ix].1.clone(),
3073 )
3074 } else {
3075 (
3076 self.display_name(&file),
3077 focus_handle.clone(),
3078 )
3079 };
3080
3081 menu = menu.entry(
3082 display_name
3083 .expect("Files should always have a name"),
3084 None,
3085 {
3086 let this = this.clone();
3087 move |window, cx| {
3088 this.update(cx, |this, cx| {
3089 this.change_file(ix, window, cx);
3090 });
3091 focus_handle.focus(window, cx);
3092 }
3093 },
3094 );
3095 }
3096
3097 menu
3098 }),
3099 )
3100 .style(DropdownStyle::Subtle)
3101 .trigger_tooltip(Tooltip::text("View Other Projects"))
3102 .trigger_icon(IconName::ChevronDown)
3103 .attach(gpui::Anchor::BottomLeft)
3104 .offset(gpui::Point {
3105 x: px(0.0),
3106 y: px(2.0),
3107 })
3108 .tab_index(0),
3109 )
3110 })
3111 }),
3112 )
3113 .child(
3114 Button::new(edit_in_json_id, "Edit in settings.json")
3115 .tab_index(0_isize)
3116 .style(ButtonStyle::OutlinedGhost)
3117 .tooltip(Tooltip::for_action_title_in(
3118 "Edit in settings.json",
3119 &OpenCurrentFile,
3120 &self.focus_handle,
3121 ))
3122 .on_click(cx.listener(|this, _, window, cx| {
3123 this.open_current_settings_file(window, cx);
3124 })),
3125 )
3126 }
3127
3128 pub(crate) fn display_name(&self, file: &SettingsUiFile) -> Option<String> {
3129 match file {
3130 SettingsUiFile::User => Some("User".to_string()),
3131 SettingsUiFile::Project((worktree_id, path)) => self
3132 .worktree_root_dirs
3133 .get(&worktree_id)
3134 .map(|directory_name| {
3135 let path_style = PathStyle::local();
3136 if path.is_empty() {
3137 directory_name.clone()
3138 } else {
3139 format!(
3140 "{}{}{}",
3141 directory_name,
3142 path_style.primary_separator(),
3143 path.display(path_style)
3144 )
3145 }
3146 }),
3147 SettingsUiFile::Server(file) => Some(file.to_string()),
3148 }
3149 }
3150
3151 // TODO:
3152 // Reconsider this after preview launch
3153 // fn file_location_str(&self) -> String {
3154 // match &self.current_file {
3155 // SettingsUiFile::User => "settings.json".to_string(),
3156 // SettingsUiFile::Project((worktree_id, path)) => self
3157 // .worktree_root_dirs
3158 // .get(&worktree_id)
3159 // .map(|directory_name| {
3160 // let path_style = PathStyle::local();
3161 // let file_path = path.join(paths::local_settings_file_relative_path());
3162 // format!(
3163 // "{}{}{}",
3164 // directory_name,
3165 // path_style.separator(),
3166 // file_path.display(path_style)
3167 // )
3168 // })
3169 // .expect("Current file should always be present in root dir map"),
3170 // SettingsUiFile::Server(file) => file.to_string(),
3171 // }
3172 // }
3173
3174 fn render_search(&self, window: &mut Window, cx: &mut App) -> impl IntoElement {
3175 let (a11y_value, a11y_text_runs) =
3176 text_field_a11y_state("settings-ui-search", &self.search_bar, window, cx);
3177
3178 h_flex()
3179 .id("settings-ui-search")
3180 .role(Role::SearchInput)
3181 .aria_label("Search Settings")
3182 .aria_value(a11y_value)
3183 .track_focus(&self.search_bar.focus_handle(cx))
3184 .a11y_synthetic_children(a11y_text_runs)
3185 .py_1()
3186 .px_1p5()
3187 .mb_3()
3188 .gap_1p5()
3189 .rounded_sm()
3190 .bg(cx.theme().colors().editor_background)
3191 .border_1()
3192 .border_color(cx.theme().colors().border)
3193 .child(Icon::new(IconName::MagnifyingGlass).color(Color::Muted))
3194 .child(self.search_bar.clone())
3195 }
3196
3197 fn render_nav(
3198 &self,
3199 window: &mut Window,
3200 cx: &mut Context<SettingsWindow>,
3201 ) -> impl IntoElement {
3202 let visible_count = self.visible_navbar_entries().count();
3203
3204 let focus_keybind_label = if self
3205 .navbar_focus_handle
3206 .read(cx)
3207 .handle
3208 .contains_focused(window, cx)
3209 || self
3210 .visible_navbar_entries()
3211 .any(|(_, entry)| entry.focus_handle.is_focused(window))
3212 {
3213 "Focus Content"
3214 } else {
3215 "Focus Navbar"
3216 };
3217
3218 let mut key_context = KeyContext::new_with_defaults();
3219 key_context.add("NavigationMenu");
3220 key_context.add("menu");
3221 if self.search_bar.focus_handle(cx).is_focused(window) {
3222 key_context.add("search");
3223 }
3224
3225 v_flex()
3226 .key_context(key_context)
3227 .on_action(cx.listener(|this, _: &CollapseNavEntry, window, cx| {
3228 let Some(focused_entry) = this.focused_nav_entry(window, cx) else {
3229 return;
3230 };
3231 let focused_entry_parent = this.root_entry_containing(focused_entry);
3232 if this.navbar_entries[focused_entry_parent].expanded {
3233 this.toggle_navbar_entry(focused_entry_parent);
3234 window.focus(&this.navbar_entries[focused_entry_parent].focus_handle, cx);
3235 }
3236 cx.notify();
3237 }))
3238 .on_action(cx.listener(|this, _: &ExpandNavEntry, window, cx| {
3239 let Some(focused_entry) = this.focused_nav_entry(window, cx) else {
3240 return;
3241 };
3242 if !this.navbar_entries[focused_entry].is_root {
3243 return;
3244 }
3245 if !this.navbar_entries[focused_entry].expanded {
3246 this.toggle_navbar_entry(focused_entry);
3247 }
3248 cx.notify();
3249 }))
3250 .on_action(
3251 cx.listener(|this, _: &FocusPreviousRootNavEntry, window, cx| {
3252 let entry_index = this
3253 .focused_nav_entry(window, cx)
3254 .unwrap_or(this.navbar_entry);
3255 let mut root_index = None;
3256 for (index, entry) in this.visible_navbar_entries() {
3257 if index >= entry_index {
3258 break;
3259 }
3260 if entry.is_root {
3261 root_index = Some(index);
3262 }
3263 }
3264 let Some(previous_root_index) = root_index else {
3265 return;
3266 };
3267 this.focus_and_scroll_to_nav_entry(previous_root_index, window, cx);
3268 }),
3269 )
3270 .on_action(cx.listener(|this, _: &FocusNextRootNavEntry, window, cx| {
3271 let entry_index = this
3272 .focused_nav_entry(window, cx)
3273 .unwrap_or(this.navbar_entry);
3274 let mut root_index = None;
3275 for (index, entry) in this.visible_navbar_entries() {
3276 if index <= entry_index {
3277 continue;
3278 }
3279 if entry.is_root {
3280 root_index = Some(index);
3281 break;
3282 }
3283 }
3284 let Some(next_root_index) = root_index else {
3285 return;
3286 };
3287 this.focus_and_scroll_to_nav_entry(next_root_index, window, cx);
3288 }))
3289 .on_action(cx.listener(|this, _: &FocusFirstNavEntry, window, cx| {
3290 if let Some((first_entry_index, _)) = this.visible_navbar_entries().next() {
3291 this.focus_and_scroll_to_nav_entry(first_entry_index, window, cx);
3292 }
3293 }))
3294 .on_action(cx.listener(|this, _: &FocusLastNavEntry, window, cx| {
3295 if let Some((last_entry_index, _)) = this.visible_navbar_entries().last() {
3296 this.focus_and_scroll_to_nav_entry(last_entry_index, window, cx);
3297 }
3298 }))
3299 .on_action(cx.listener(|this, _: &FocusNextNavEntry, window, cx| {
3300 let entry_index = this
3301 .focused_nav_entry(window, cx)
3302 .unwrap_or(this.navbar_entry);
3303 let mut next_index = None;
3304 for (index, _) in this.visible_navbar_entries() {
3305 if index > entry_index {
3306 next_index = Some(index);
3307 break;
3308 }
3309 }
3310 let Some(next_entry_index) = next_index else {
3311 return;
3312 };
3313 this.open_and_scroll_to_navbar_entry(
3314 next_entry_index,
3315 Some(gpui::ScrollStrategy::Bottom),
3316 false,
3317 window,
3318 cx,
3319 );
3320 }))
3321 .on_action(cx.listener(|this, _: &FocusPreviousNavEntry, window, cx| {
3322 let entry_index = this
3323 .focused_nav_entry(window, cx)
3324 .unwrap_or(this.navbar_entry);
3325 let mut prev_index = None;
3326 for (index, _) in this.visible_navbar_entries() {
3327 if index >= entry_index {
3328 break;
3329 }
3330 prev_index = Some(index);
3331 }
3332 let Some(prev_entry_index) = prev_index else {
3333 return;
3334 };
3335 this.open_and_scroll_to_navbar_entry(
3336 prev_entry_index,
3337 Some(gpui::ScrollStrategy::Top),
3338 false,
3339 window,
3340 cx,
3341 );
3342 }))
3343 .w(SIDEBAR_WIDTH)
3344 .h_full()
3345 .p_2p5()
3346 .when(cfg!(target_os = "macos"), |this| this.pt_10())
3347 .flex_none()
3348 .border_r_1()
3349 .border_color(cx.theme().colors().border)
3350 .bg(cx.theme().colors().panel_background)
3351 .child(self.render_search(window, cx))
3352 .child(
3353 v_flex()
3354 .id("settings-ui-nav")
3355 .role(Role::Tree)
3356 .aria_label("Settings Navigation")
3357 .flex_1()
3358 .overflow_hidden()
3359 .track_focus(&self.navbar_focus_handle.focus_handle(cx))
3360 .tab_group()
3361 .tab_index(NAVBAR_GROUP_TAB_INDEX)
3362 .child(
3363 uniform_list(
3364 "settings-ui-nav-bar",
3365 visible_count + 1,
3366 cx.processor(move |this, range: Range<usize>, _, cx| {
3367 this.visible_navbar_entries()
3368 .skip(range.start.saturating_sub(1))
3369 .take(range.len())
3370 .map(|(entry_index, entry)| {
3371 TreeViewItem::new(
3372 ("settings-ui-navbar-entry", entry_index),
3373 entry.title,
3374 )
3375 .track_focus(&entry.focus_handle)
3376 .root_item(entry.is_root)
3377 .toggle_state(this.is_navbar_entry_selected(entry_index))
3378 .when(entry.is_root, |item| {
3379 item.expanded(entry.expanded || this.has_query)
3380 .on_toggle(cx.listener(
3381 move |this, _, window, cx| {
3382 this.toggle_and_focus_navbar_entry(
3383 entry_index,
3384 window,
3385 cx,
3386 );
3387 },
3388 ))
3389 })
3390 .on_click({
3391 let category = this.pages[entry.page_index].title;
3392 let subcategory =
3393 (!entry.is_root).then_some(entry.title);
3394
3395 cx.listener(move |this, event: &gpui::ClickEvent, window, cx| {
3396 if this.toggle_navbar_entry_on_double_click(
3397 entry_index,
3398 event,
3399 window,
3400 cx,
3401 )
3402 {
3403 return;
3404 }
3405
3406 telemetry::event!(
3407 "Settings Navigation Clicked",
3408 category = category,
3409 subcategory = subcategory
3410 );
3411
3412 this.open_and_scroll_to_navbar_entry(
3413 entry_index,
3414 None,
3415 true,
3416 window,
3417 cx,
3418 );
3419 })
3420 })
3421 })
3422 .collect()
3423 }),
3424 )
3425 .size_full()
3426 .track_scroll(&self.navbar_scroll_handle),
3427 )
3428 .vertical_scrollbar_for(&self.navbar_scroll_handle, window, cx),
3429 )
3430 .child(
3431 h_flex()
3432 .w_full()
3433 .h_8()
3434 .p_2()
3435 .pb_0p5()
3436 .flex_shrink_0()
3437 .border_t_1()
3438 .border_color(cx.theme().colors().border_variant)
3439 .child(
3440 KeybindingHint::new(
3441 KeyBinding::for_action_in(
3442 &ToggleFocusNav,
3443 &self.navbar_focus_handle.focus_handle(cx),
3444 cx,
3445 ),
3446 cx.theme().colors().surface_background.opacity(0.5),
3447 )
3448 .suffix(focus_keybind_label),
3449 ),
3450 )
3451 }
3452
3453 fn open_and_scroll_to_navbar_entry(
3454 &mut self,
3455 navbar_entry_index: usize,
3456 scroll_strategy: Option<gpui::ScrollStrategy>,
3457 focus_content: bool,
3458 window: &mut Window,
3459 cx: &mut Context<Self>,
3460 ) {
3461 self.open_navbar_entry_page(navbar_entry_index);
3462 cx.notify();
3463
3464 let mut handle_to_focus = None;
3465
3466 if self.navbar_entries[navbar_entry_index].is_root
3467 || !self.is_nav_entry_visible(navbar_entry_index)
3468 {
3469 if let Some(scroll_handle) = self.current_sub_page_scroll_handle() {
3470 scroll_handle.set_offset(point(px(0.), px(0.)));
3471 }
3472
3473 if focus_content {
3474 let Some(first_item_index) =
3475 self.visible_page_items().next().map(|(index, _)| index)
3476 else {
3477 return;
3478 };
3479 handle_to_focus = Some(self.focus_handle_for_content_element(first_item_index, cx));
3480 } else if !self.is_nav_entry_visible(navbar_entry_index) {
3481 let Some(first_visible_nav_entry_index) =
3482 self.visible_navbar_entries().next().map(|(index, _)| index)
3483 else {
3484 return;
3485 };
3486 self.focus_and_scroll_to_nav_entry(first_visible_nav_entry_index, window, cx);
3487 } else {
3488 handle_to_focus =
3489 Some(self.navbar_entries[navbar_entry_index].focus_handle.clone());
3490 }
3491 } else {
3492 let entry_item_index = self.navbar_entries[navbar_entry_index]
3493 .item_index
3494 .expect("Non-root items should have an item index");
3495 self.scroll_to_content_item(entry_item_index, window, cx);
3496 if focus_content {
3497 handle_to_focus = Some(self.focus_handle_for_content_element(entry_item_index, cx));
3498 } else {
3499 handle_to_focus =
3500 Some(self.navbar_entries[navbar_entry_index].focus_handle.clone());
3501 }
3502 }
3503
3504 if let Some(scroll_strategy) = scroll_strategy
3505 && let Some(logical_entry_index) = self
3506 .visible_navbar_entries()
3507 .into_iter()
3508 .position(|(index, _)| index == navbar_entry_index)
3509 {
3510 self.navbar_scroll_handle
3511 .scroll_to_item(logical_entry_index + 1, scroll_strategy);
3512 }
3513
3514 // Page scroll handle updates the active item index
3515 // in it's next paint call after using scroll_handle.scroll_to_top_of_item
3516 // The call after that updates the offset of the scroll handle. So to
3517 // ensure the scroll handle doesn't lag behind we need to render three frames
3518 // back to back.
3519 cx.on_next_frame(window, move |_, window, cx| {
3520 if let Some(handle) = handle_to_focus.as_ref() {
3521 window.focus(handle, cx);
3522 }
3523
3524 cx.on_next_frame(window, |_, _, cx| {
3525 cx.notify();
3526 });
3527 cx.notify();
3528 });
3529 cx.notify();
3530 }
3531
3532 fn scroll_to_content_item(
3533 &self,
3534 content_item_index: usize,
3535 _window: &mut Window,
3536 cx: &mut Context<Self>,
3537 ) {
3538 let index = self
3539 .visible_page_items()
3540 .position(|(index, _)| index == content_item_index)
3541 .unwrap_or(0);
3542 if index == 0 {
3543 if let Some(scroll_handle) = self.current_sub_page_scroll_handle() {
3544 scroll_handle.set_offset(point(px(0.), px(0.)));
3545 }
3546
3547 self.list_state.scroll_to(gpui::ListOffset {
3548 item_ix: 0,
3549 offset_in_item: px(0.),
3550 });
3551 return;
3552 }
3553 self.list_state.scroll_to(gpui::ListOffset {
3554 item_ix: index + 1,
3555 offset_in_item: px(0.),
3556 });
3557 cx.notify();
3558 }
3559
3560 fn is_nav_entry_visible(&self, nav_entry_index: usize) -> bool {
3561 self.visible_navbar_entries()
3562 .any(|(index, _)| index == nav_entry_index)
3563 }
3564
3565 fn focus_and_scroll_to_first_visible_nav_entry(
3566 &self,
3567 window: &mut Window,
3568 cx: &mut Context<Self>,
3569 ) {
3570 if let Some(nav_entry_index) = self.visible_navbar_entries().next().map(|(index, _)| index)
3571 {
3572 self.focus_and_scroll_to_nav_entry(nav_entry_index, window, cx);
3573 }
3574 }
3575
3576 fn focus_and_scroll_to_nav_entry(
3577 &self,
3578 nav_entry_index: usize,
3579 window: &mut Window,
3580 cx: &mut Context<Self>,
3581 ) {
3582 let Some(position) = self
3583 .visible_navbar_entries()
3584 .position(|(index, _)| index == nav_entry_index)
3585 else {
3586 return;
3587 };
3588 self.navbar_scroll_handle
3589 .scroll_to_item(position, gpui::ScrollStrategy::Top);
3590 window.focus(&self.navbar_entries[nav_entry_index].focus_handle, cx);
3591 cx.notify();
3592 }
3593
3594 fn current_sub_page_scroll_handle(&self) -> Option<&ScrollHandle> {
3595 self.sub_page_stack.last().map(|page| &page.scroll_handle)
3596 }
3597
3598 fn visible_page_items(&self) -> impl Iterator<Item = (usize, &SettingsPageItem)> {
3599 let page_idx = self.current_page_index();
3600
3601 self.current_page()
3602 .items
3603 .iter()
3604 .enumerate()
3605 .filter(move |&(item_index, _)| self.filter_table[page_idx][item_index])
3606 }
3607
3608 fn render_sub_page_breadcrumbs(
3609 &self,
3610 window: &mut Window,
3611 cx: &mut Context<Self>,
3612 ) -> impl IntoElement {
3613 let scope_name: SharedString = self
3614 .display_name(&self.current_file)
3615 .unwrap_or_else(|| self.current_file.setting_type().to_string())
3616 .into();
3617
3618 // Only offer scopes in which every sub-page in the stack is available.
3619 let allowed_mask = self
3620 .sub_page_stack
3621 .iter()
3622 .fold(USER | PROJECT | SERVER, |mask, sub_page| {
3623 mask & sub_page.link.files
3624 });
3625 let allowed_file_indices: Vec<usize> = self
3626 .files
3627 .iter()
3628 .enumerate()
3629 .filter(|(_, (file, _))| allowed_mask.contains(file.mask()))
3630 .map(|(ix, _)| ix)
3631 .collect();
3632
3633 let scope_element = if allowed_file_indices.len() > 1 {
3634 let this = cx.entity();
3635 DropdownMenu::new(
3636 "sub-page-scope-picker",
3637 scope_name,
3638 ContextMenu::build(window, cx, move |mut menu, _, _| {
3639 menu = menu.header("Scope");
3640
3641 for ix in allowed_file_indices {
3642 let (file, focus_handle) = &self.files[ix];
3643 let display_name = self
3644 .display_name(file)
3645 .expect("Files should always have a name");
3646
3647 menu = menu.toggleable_entry(
3648 display_name,
3649 file == &self.current_file,
3650 IconPosition::End,
3651 None,
3652 {
3653 let this = this.clone();
3654 let focus_handle = focus_handle.clone();
3655 move |window, cx| {
3656 this.update(cx, |this, cx| {
3657 this.change_file_in_sub_page(ix, window, cx);
3658 });
3659 focus_handle.focus(window, cx);
3660 }
3661 },
3662 );
3663 }
3664
3665 menu
3666 }),
3667 )
3668 .style(DropdownStyle::Subtle)
3669 .trigger_tooltip(Tooltip::text("Change Scope"))
3670 .attach(gpui::Anchor::BottomLeft)
3671 .offset(gpui::Point {
3672 x: px(0.0),
3673 y: px(2.0),
3674 })
3675 .tab_index(0)
3676 .into_any_element()
3677 } else {
3678 Label::new(scope_name)
3679 .color(Color::Muted)
3680 .into_any_element()
3681 };
3682
3683 h_flex()
3684 .min_w_0()
3685 .gap_1()
3686 .overflow_x_hidden()
3687 .child(scope_element)
3688 .child(Label::new("/").color(Color::Muted))
3689 .children(
3690 itertools::intersperse(
3691 std::iter::once(self.current_page().title.into()).chain(
3692 self.sub_page_stack
3693 .iter()
3694 .enumerate()
3695 .flat_map(|(index, page)| {
3696 (index == 0)
3697 .then(|| page.section_header.clone())
3698 .into_iter()
3699 .chain(std::iter::once(page.link.title.clone()))
3700 }),
3701 ),
3702 "/".into(),
3703 )
3704 .map(|item| Label::new(item).color(Color::Muted)),
3705 )
3706 }
3707
3708 fn render_no_results(&self, cx: &App) -> impl IntoElement {
3709 let search_query = self.search_bar.read(cx).text(cx);
3710
3711 v_flex()
3712 .size_full()
3713 .items_center()
3714 .justify_center()
3715 .gap_1()
3716 .child(Label::new("No Results"))
3717 .child(
3718 Label::new(format!("No settings match \"{}\"", search_query))
3719 .size(LabelSize::Small)
3720 .color(Color::Muted),
3721 )
3722 }
3723
3724 fn render_current_page_items(
3725 &mut self,
3726 _window: &mut Window,
3727 cx: &mut Context<SettingsWindow>,
3728 ) -> impl IntoElement {
3729 let current_page_index = self.current_page_index();
3730 let mut page_content = v_flex()
3731 .id("settings-ui-page")
3732 .role(Role::Group)
3733 .aria_label("Settings Content")
3734 .size_full();
3735
3736 let has_active_search = !self.search_bar.read(cx).is_empty(cx);
3737 let has_no_results = self.visible_page_items().next().is_none() && has_active_search;
3738
3739 if has_no_results {
3740 page_content = page_content.child(self.render_no_results(cx))
3741 } else {
3742 let last_non_header_index = self
3743 .visible_page_items()
3744 .filter_map(|(index, item)| {
3745 (!matches!(item, SettingsPageItem::SectionHeader(_))).then_some(index)
3746 })
3747 .last();
3748
3749 let root_nav_label = self
3750 .navbar_entries
3751 .iter()
3752 .find(|entry| entry.is_root && entry.page_index == self.current_page_index())
3753 .map(|entry| entry.title);
3754
3755 let list_content = list(
3756 self.list_state.clone(),
3757 cx.processor(move |this, index, window, cx| {
3758 if index == 0 {
3759 return div()
3760 .px_8()
3761 .when(this.sub_page_stack.is_empty(), |this| {
3762 this.when_some(root_nav_label, |this, title| {
3763 this.child(
3764 Label::new(title).size(LabelSize::Large).mt_2().mb_3(),
3765 )
3766 })
3767 })
3768 .into_any_element();
3769 }
3770
3771 let mut visible_items = this.visible_page_items();
3772 let Some((actual_item_index, item)) = visible_items.nth(index - 1) else {
3773 return gpui::Empty.into_any_element();
3774 };
3775
3776 let next_is_header = visible_items
3777 .next()
3778 .map(|(_, item)| matches!(item, SettingsPageItem::SectionHeader(_)))
3779 .unwrap_or(false);
3780
3781 let is_last = Some(actual_item_index) == last_non_header_index;
3782 let is_last_in_section = next_is_header || is_last;
3783
3784 let bottom_border = !is_last_in_section;
3785 let extra_bottom_padding = is_last_in_section;
3786
3787 let item_focus_handle = this.content_handles[current_page_index]
3788 [actual_item_index]
3789 .focus_handle(cx);
3790
3791 v_flex()
3792 .id(("settings-page-item", actual_item_index))
3793 .track_focus(&item_focus_handle)
3794 .w_full()
3795 .min_w_0()
3796 .child(item.render(
3797 this,
3798 actual_item_index,
3799 bottom_border,
3800 extra_bottom_padding,
3801 window,
3802 cx,
3803 ))
3804 .into_any_element()
3805 }),
3806 );
3807
3808 page_content = page_content.child(list_content.size_full())
3809 }
3810 page_content
3811 }
3812
3813 fn render_sub_page_items<'a, Items>(
3814 &self,
3815 items: Items,
3816 scroll_handle: &ScrollHandle,
3817 window: &mut Window,
3818 cx: &mut Context<SettingsWindow>,
3819 ) -> impl IntoElement
3820 where
3821 Items: Iterator<Item = (usize, &'a SettingsPageItem)>,
3822 {
3823 let page_content = v_flex()
3824 .id("settings-ui-page")
3825 .size_full()
3826 .overflow_y_scroll()
3827 .track_scroll(scroll_handle);
3828 self.render_sub_page_items_in(page_content, items, false, window, cx)
3829 }
3830
3831 fn render_sub_page_items_section<'a, Items>(
3832 &self,
3833 items: Items,
3834 is_inline_section: bool,
3835 window: &mut Window,
3836 cx: &mut Context<SettingsWindow>,
3837 ) -> impl IntoElement
3838 where
3839 Items: Iterator<Item = (usize, &'a SettingsPageItem)>,
3840 {
3841 let page_content = v_flex().id("settings-ui-sub-page-section").size_full();
3842 self.render_sub_page_items_in(page_content, items, is_inline_section, window, cx)
3843 }
3844
3845 fn render_sub_page_items_in<'a, Items>(
3846 &self,
3847 page_content: Stateful<Div>,
3848 items: Items,
3849 is_inline_section: bool,
3850 window: &mut Window,
3851 cx: &mut Context<SettingsWindow>,
3852 ) -> impl IntoElement
3853 where
3854 Items: Iterator<Item = (usize, &'a SettingsPageItem)>,
3855 {
3856 let items: Vec<_> = items.collect();
3857 let items_len = items.len();
3858
3859 let has_active_search = !self.search_bar.read(cx).is_empty(cx);
3860 let has_no_results = items_len == 0 && has_active_search;
3861
3862 if has_no_results {
3863 page_content.child(self.render_no_results(cx))
3864 } else {
3865 let last_non_header_index = items
3866 .iter()
3867 .enumerate()
3868 .rev()
3869 .find(|(_, (_, item))| !matches!(item, SettingsPageItem::SectionHeader(_)))
3870 .map(|(index, _)| index);
3871
3872 let root_nav_label = self
3873 .navbar_entries
3874 .iter()
3875 .find(|entry| entry.is_root && entry.page_index == self.current_page_index())
3876 .map(|entry| entry.title);
3877
3878 page_content
3879 .when(self.sub_page_stack.is_empty(), |this| {
3880 this.when_some(root_nav_label, |this, title| {
3881 this.child(Label::new(title).size(LabelSize::Large).mt_2().mb_3())
3882 })
3883 })
3884 .children(items.clone().into_iter().enumerate().map(
3885 |(index, (actual_item_index, item))| {
3886 let is_last_item = Some(index) == last_non_header_index;
3887 let next_is_header = items.get(index + 1).is_some_and(|(_, next_item)| {
3888 matches!(next_item, SettingsPageItem::SectionHeader(_))
3889 });
3890 let bottom_border = !is_inline_section && !next_is_header && !is_last_item;
3891
3892 let extra_bottom_padding =
3893 !is_inline_section && (next_is_header || is_last_item);
3894
3895 v_flex()
3896 .w_full()
3897 .min_w_0()
3898 .id(("settings-page-item", actual_item_index))
3899 .child(item.render(
3900 self,
3901 actual_item_index,
3902 bottom_border,
3903 extra_bottom_padding,
3904 window,
3905 cx,
3906 ))
3907 },
3908 ))
3909 }
3910 }
3911
3912 fn render_page(
3913 &mut self,
3914 window: &mut Window,
3915 cx: &mut Context<SettingsWindow>,
3916 ) -> impl IntoElement {
3917 let page_header;
3918 let page_content;
3919
3920 if let Some(current_sub_page) = self.sub_page_stack.last() {
3921 let is_skills_page =
3922 current_sub_page.link.json_path == Some(AGENT_SKILLS_SETTINGS_PATH);
3923 let is_llm_providers_page = current_sub_page.link.json_path == Some("llm_providers")
3924 && current_sub_page.link.title.as_ref() == "LLM Providers";
3925 let is_external_agents_page = current_sub_page.link.json_path == Some("agent_servers");
3926 let is_mcp_servers_page = current_sub_page.link.json_path == Some("context_servers");
3927
3928 page_header = h_flex()
3929 .w_full()
3930 .min_w_0()
3931 .justify_between()
3932 .child(
3933 h_flex()
3934 .min_w_0()
3935 .ml_neg_1p5()
3936 .gap_1()
3937 .child(
3938 IconButton::new("back-btn", IconName::ArrowLeft)
3939 .icon_size(IconSize::Small)
3940 .shape(IconButtonShape::Square)
3941 .on_click(cx.listener(|this, _, window, cx| {
3942 this.pop_sub_page(window, cx);
3943 })),
3944 )
3945 .child(self.render_sub_page_breadcrumbs(window, cx)),
3946 )
3947 .child(
3948 div()
3949 .flex_shrink_0()
3950 .when(current_sub_page.link.in_json, |this| {
3951 this.child(
3952 Button::new("open-in-settings-file", "Edit in settings.json")
3953 .tab_index(0_isize)
3954 .style(ButtonStyle::OutlinedGhost)
3955 .tooltip(Tooltip::for_action_title_in(
3956 "Edit in settings.json",
3957 &OpenCurrentFile,
3958 &self.focus_handle,
3959 ))
3960 .on_click(cx.listener(|this, _, window, cx| {
3961 this.open_current_settings_file(window, cx);
3962 })),
3963 )
3964 })
3965 .when(is_llm_providers_page, |this| {
3966 this.child(pages::render_add_llm_provider_popover(self, window, cx))
3967 })
3968 .when(is_skills_page, |this| {
3969 this.child(
3970 Button::new("open-skill-creator", "Create Skill")
3971 .tab_index(0_isize)
3972 .style(ButtonStyle::OutlinedGhost)
3973 .on_click(cx.listener(|this, _, window, cx| {
3974 this.open_skill_creator_sub_page(
3975 pages::SkillCreatorOpenMode::Form,
3976 window,
3977 cx,
3978 );
3979 })),
3980 )
3981 })
3982 .when(is_external_agents_page, |this| {
3983 this.child(pages::render_add_agent_popover(self, window, cx))
3984 })
3985 .when(is_mcp_servers_page, |this| {
3986 this.child(pages::render_add_server_popover(self, window, cx))
3987 }),
3988 )
3989 .into_any_element();
3990
3991 let active_page_render_fn = ¤t_sub_page.link.render;
3992 page_content =
3993 (active_page_render_fn)(self, ¤t_sub_page.scroll_handle, window, cx);
3994 } else {
3995 page_header = self.render_files_header(window, cx).into_any_element();
3996
3997 page_content = self
3998 .render_current_page_items(window, cx)
3999 .into_any_element();
4000 }
4001
4002 let current_sub_page = self.sub_page_stack.last();
4003
4004 let mut warning_banner = gpui::Empty.into_any_element();
4005 if let Some(error) =
4006 SettingsStore::global(cx).error_for_file(self.current_file.to_settings())
4007 {
4008 fn banner(
4009 label: &'static str,
4010 error: String,
4011 shown_errors: &mut HashSet<String>,
4012 cx: &mut Context<SettingsWindow>,
4013 ) -> impl IntoElement {
4014 if shown_errors.insert(error.clone()) {
4015 telemetry::event!("Settings Error Shown", label = label, error = &error);
4016 }
4017 Banner::new()
4018 .severity(Severity::Warning)
4019 .child(
4020 v_flex()
4021 .my_0p5()
4022 .gap_0p5()
4023 .child(Label::new(label))
4024 .child(Label::new(error).size(LabelSize::Small).color(Color::Muted)),
4025 )
4026 .action_slot(
4027 div().pr_1().pb_1().child(
4028 Button::new("fix-in-json", "Fix in settings.json")
4029 .tab_index(0_isize)
4030 .style(ButtonStyle::Tinted(ui::TintColor::Warning))
4031 .on_click(cx.listener(|this, _, window, cx| {
4032 this.open_current_settings_file(window, cx);
4033 })),
4034 ),
4035 )
4036 }
4037
4038 let parse_error = error.parse_error();
4039 let parse_failed = parse_error.is_some();
4040
4041 warning_banner = v_flex()
4042 .gap_2()
4043 .when_some(parse_error, |this, err| {
4044 this.child(banner(
4045 "Failed to load your settings. Some values may be incorrect and changes may be lost.",
4046 err,
4047 &mut self.shown_errors,
4048 cx,
4049 ))
4050 })
4051 .map(|this| match &error.migration_status {
4052 settings::MigrationStatus::Succeeded => this.child(banner(
4053 "Your settings are out of date, and need to be updated.",
4054 match &self.current_file {
4055 SettingsUiFile::User => "They can be automatically migrated to the latest version.",
4056 SettingsUiFile::Server(_) | SettingsUiFile::Project(_) => "They must be manually migrated to the latest version."
4057 }.to_string(),
4058 &mut self.shown_errors,
4059 cx,
4060 )),
4061 settings::MigrationStatus::Failed { error: err } if !parse_failed => this
4062 .child(banner(
4063 "Your settings file is out of date, automatic migration failed",
4064 err.clone(),
4065 &mut self.shown_errors,
4066 cx,
4067 )),
4068 _ => this,
4069 })
4070 .into_any_element()
4071 }
4072
4073 v_flex()
4074 .id("settings-ui-page")
4075 .on_action(cx.listener(|this, _: &menu::SelectNext, window, cx| {
4076 if !this.sub_page_stack.is_empty() {
4077 // Keep Tab navigation within the sub-page content. Global
4078 // `focus_next` would otherwise wrap past the last control to
4079 // the navbar; instead, when focus leaves the content region we
4080 // wrap back to the first content tab stop.
4081 let content_handle = this.content_focus_handle.focus_handle(cx);
4082 window.focus_next(cx);
4083 if !content_handle.contains_focused(window, cx) {
4084 content_handle.focus(window, cx);
4085 window.focus_next(cx);
4086 }
4087 return;
4088 }
4089 for (logical_index, (actual_index, _)) in this.visible_page_items().enumerate() {
4090 let handle = this.content_handles[this.current_page_index()][actual_index]
4091 .focus_handle(cx);
4092 let mut offset = 1; // for page header
4093
4094 if let Some((_, next_item)) = this.visible_page_items().nth(logical_index + 1)
4095 && matches!(next_item, SettingsPageItem::SectionHeader(_))
4096 {
4097 offset += 1;
4098 }
4099 if handle.contains_focused(window, cx) {
4100 let next_logical_index = logical_index + offset + 1;
4101 this.list_state.scroll_to_reveal_item(next_logical_index);
4102 // We need to render the next item to ensure it's focus handle is in the element tree
4103 cx.on_next_frame(window, |_, window, cx| {
4104 cx.notify();
4105 cx.on_next_frame(window, |_, window, cx| {
4106 window.focus_next(cx);
4107 cx.notify();
4108 });
4109 });
4110 cx.notify();
4111 return;
4112 }
4113 }
4114 window.focus_next(cx);
4115 }))
4116 .on_action(cx.listener(|this, _: &menu::SelectPrevious, window, cx| {
4117 if !this.sub_page_stack.is_empty() {
4118 window.focus_prev(cx);
4119 return;
4120 }
4121 let mut prev_was_header = false;
4122 for (logical_index, (actual_index, item)) in this.visible_page_items().enumerate() {
4123 let is_header = matches!(item, SettingsPageItem::SectionHeader(_));
4124 let handle = this.content_handles[this.current_page_index()][actual_index]
4125 .focus_handle(cx);
4126 let mut offset = 1; // for page header
4127
4128 if prev_was_header {
4129 offset -= 1;
4130 }
4131 if handle.contains_focused(window, cx) {
4132 let next_logical_index = logical_index + offset - 1;
4133 this.list_state.scroll_to_reveal_item(next_logical_index);
4134 // We need to render the next item to ensure it's focus handle is in the element tree
4135 cx.on_next_frame(window, |_, window, cx| {
4136 cx.notify();
4137 cx.on_next_frame(window, |_, window, cx| {
4138 window.focus_prev(cx);
4139 cx.notify();
4140 });
4141 });
4142 cx.notify();
4143 return;
4144 }
4145 prev_was_header = is_header;
4146 }
4147 window.focus_prev(cx);
4148 }))
4149 .when(current_sub_page.is_none(), |this| {
4150 this.vertical_scrollbar_for(&self.list_state, window, cx)
4151 })
4152 .when_some(current_sub_page, |this, current_sub_page| {
4153 this.custom_scrollbars(
4154 Scrollbars::new(ui::ScrollAxes::Vertical)
4155 .tracked_scroll_handle(¤t_sub_page.scroll_handle)
4156 .id((current_sub_page.link.title.clone(), 42)),
4157 window,
4158 cx,
4159 )
4160 })
4161 .track_focus(&self.content_focus_handle.focus_handle(cx))
4162 .pt_6()
4163 .gap_4()
4164 .flex_1()
4165 .min_w_0()
4166 .bg(cx.theme().colors().editor_background)
4167 .child(
4168 v_flex()
4169 .px_8()
4170 .gap_2()
4171 .child(page_header)
4172 .child(warning_banner)
4173 )
4174 .child(
4175 div()
4176 .flex_1()
4177 .min_h_0()
4178 .size_full()
4179 .tab_group()
4180 .tab_index(CONTENT_GROUP_TAB_INDEX)
4181 .child(page_content),
4182 )
4183 }
4184
4185 /// This function will create a new settings file if one doesn't exist
4186 /// if the current file is a project settings with a valid worktree id
4187 /// We do this because the settings ui allows initializing project settings
4188 pub(crate) fn open_current_settings_file(
4189 &mut self,
4190 window: &mut Window,
4191 cx: &mut Context<Self>,
4192 ) {
4193 match &self.current_file {
4194 SettingsUiFile::User => {
4195 let Some(original_window) = self.original_window else {
4196 return;
4197 };
4198 original_window
4199 .update(cx, |multi_workspace, window, cx| {
4200 multi_workspace
4201 .workspace()
4202 .clone()
4203 .update(cx, |workspace, cx| {
4204 workspace
4205 .with_local_or_wsl_workspace(
4206 window,
4207 cx,
4208 open_user_settings_in_workspace,
4209 )
4210 .detach();
4211 });
4212 })
4213 .ok();
4214
4215 window.remove_window();
4216 }
4217 SettingsUiFile::Project((worktree_id, path)) => {
4218 let settings_path = path.join(paths::local_settings_file_relative_path());
4219 let app_state = workspace::AppState::global(cx);
4220
4221 let Some((workspace_window, worktree, corresponding_workspace)) = app_state
4222 .workspace_store
4223 .read(cx)
4224 .workspaces_with_windows()
4225 .filter_map(|(window_handle, weak)| {
4226 let workspace = weak.upgrade()?;
4227 let window = window_handle.downcast::<MultiWorkspace>()?;
4228 Some((window, workspace))
4229 })
4230 .find_map(|(window, workspace): (_, Entity<Workspace>)| {
4231 workspace
4232 .read(cx)
4233 .project()
4234 .read(cx)
4235 .worktree_for_id(*worktree_id, cx)
4236 .map(|worktree| (window, worktree, workspace))
4237 })
4238 else {
4239 log::error!(
4240 "No corresponding workspace contains worktree id: {}",
4241 worktree_id
4242 );
4243
4244 return;
4245 };
4246
4247 let create_task = if worktree.read(cx).entry_for_path(&settings_path).is_some() {
4248 None
4249 } else {
4250 Some(worktree.update(cx, |tree, cx| {
4251 tree.create_entry(
4252 settings_path.clone().into(),
4253 false,
4254 Some(initial_project_settings_content().as_bytes().to_vec()),
4255 cx,
4256 )
4257 }))
4258 };
4259
4260 let worktree_id = *worktree_id;
4261
4262 // TODO: move zed::open_local_file() APIs to this crate, and
4263 // re-implement the "initial_contents" behavior
4264 let workspace_weak = corresponding_workspace.downgrade();
4265 workspace_window
4266 .update(cx, |_, window, cx| {
4267 cx.spawn_in(window, async move |_, cx| {
4268 if let Some(create_task) = create_task {
4269 create_task.await.ok()?;
4270 };
4271
4272 workspace_weak
4273 .update_in(cx, |workspace, window, cx| {
4274 workspace.open_path(
4275 (worktree_id, settings_path.clone()),
4276 None,
4277 true,
4278 window,
4279 cx,
4280 )
4281 })
4282 .ok()?
4283 .await
4284 .log_err()?;
4285
4286 workspace_weak
4287 .update_in(cx, |_, window, cx| {
4288 window.activate_window();
4289 cx.notify();
4290 })
4291 .ok();
4292
4293 Some(())
4294 })
4295 .detach();
4296 })
4297 .ok();
4298
4299 window.remove_window();
4300 }
4301 SettingsUiFile::Server(_) => {
4302 // Server files are not editable
4303 return;
4304 }
4305 };
4306 }
4307
4308 fn current_page_index(&self) -> usize {
4309 if self.navbar_entries.is_empty() {
4310 return 0;
4311 }
4312
4313 self.navbar_entries[self.navbar_entry].page_index
4314 }
4315
4316 fn current_page(&self) -> &SettingsPage {
4317 &self.pages[self.current_page_index()]
4318 }
4319
4320 fn is_navbar_entry_selected(&self, ix: usize) -> bool {
4321 ix == self.navbar_entry
4322 }
4323
4324 fn push_sub_page(
4325 &mut self,
4326 sub_page_link: SubPageLink,
4327 section_header: SharedString,
4328 window: &mut Window,
4329 cx: &mut Context<SettingsWindow>,
4330 ) {
4331 self.sandbox_host_validation_error = None;
4332 self.sub_page_stack
4333 .push(SubPage::new(sub_page_link, section_header));
4334 self.content_focus_handle.focus_handle(cx).focus(window, cx);
4335 cx.notify();
4336 }
4337
4338 /// Push a dynamically-created sub-page with a custom render function.
4339 /// This is useful for nested sub-pages that aren't defined in the main pages list.
4340 pub fn push_dynamic_sub_page(
4341 &mut self,
4342 title: impl Into<SharedString>,
4343 section_header: impl Into<SharedString>,
4344 json_path: Option<&'static str>,
4345 in_json: bool,
4346 render: fn(
4347 &SettingsWindow,
4348 &ScrollHandle,
4349 &mut Window,
4350 &mut Context<SettingsWindow>,
4351 ) -> AnyElement,
4352 window: &mut Window,
4353 cx: &mut Context<SettingsWindow>,
4354 ) {
4355 self.regex_validation_error = None;
4356 let sub_page_link = SubPageLink {
4357 title: title.into(),
4358 r#type: SubPageType::default(),
4359 description: None,
4360 search_aliases: &[],
4361 json_path,
4362 in_json,
4363 files: USER,
4364 render,
4365 };
4366 self.push_sub_page(sub_page_link, section_header.into(), window, cx);
4367 }
4368
4369 pub(crate) fn skill_creator_page(&self) -> Option<Entity<pages::SkillCreatorPage>> {
4370 self.skill_creator_page
4371 .as_ref()
4372 .map(|(page, _)| page.clone())
4373 }
4374
4375 /// If the creator is already the active sub-page, the open mode is applied
4376 /// to the existing form instead
4377 pub fn open_skill_creator_sub_page(
4378 &mut self,
4379 open_mode: pages::SkillCreatorOpenMode,
4380 window: &mut Window,
4381 cx: &mut Context<SettingsWindow>,
4382 ) {
4383 let creator_is_active_sub_page = self
4384 .sub_page_stack
4385 .last()
4386 .is_some_and(|sub_page| sub_page.link.r#type == SubPageType::SkillCreator);
4387
4388 if creator_is_active_sub_page && let Some((page, _)) = &self.skill_creator_page {
4389 let page = page.clone();
4390 page.update(cx, |page, cx| page.apply_open_mode(open_mode, window, cx));
4391 return;
4392 }
4393
4394 let settings_window = cx.weak_entity();
4395 let page = cx.new(|cx| pages::SkillCreatorPage::new(settings_window, window, cx));
4396
4397 let subscription =
4398 cx.subscribe_in(
4399 &page,
4400 window,
4401 |this, _page, event: &pages::SkillCreatorEvent, window, cx| match event {
4402 pages::SkillCreatorEvent::Dismissed | pages::SkillCreatorEvent::Saved => {
4403 if this.sub_page_stack.last().is_some_and(|sub_page| {
4404 sub_page.link.r#type == SubPageType::SkillCreator
4405 }) {
4406 this.pop_sub_page(window, cx);
4407 }
4408 }
4409 },
4410 );
4411
4412 self.skill_creator_page = Some((page.clone(), subscription));
4413
4414 let sub_page_link = SubPageLink {
4415 title: "Create Skill".into(),
4416 r#type: SubPageType::SkillCreator,
4417 description: None,
4418 search_aliases: &[],
4419 json_path: None,
4420 in_json: false,
4421 files: USER | PROJECT,
4422 render: pages::render_skill_creator_page,
4423 };
4424
4425 self.push_sub_page(sub_page_link, "Agent".into(), window, cx);
4426
4427 let creating_from_url = !matches!(open_mode, pages::SkillCreatorOpenMode::Url { .. });
4428 page.update(cx, |page, cx| {
4429 page.apply_open_mode(open_mode, window, cx);
4430 });
4431 if creating_from_url {
4432 let name_editor_focus_handle = page.read(cx).name_editor_focus_handle(cx);
4433 window.focus(&name_editor_focus_handle, cx);
4434 }
4435 }
4436
4437 pub fn navigate_to_skill_creator(
4438 &mut self,
4439 open_mode: pages::SkillCreatorOpenMode,
4440 window: &mut Window,
4441 cx: &mut Context<SettingsWindow>,
4442 ) {
4443 self.sub_page_stack.clear();
4444 let skills_page_index = self.pages.iter().position(|page| {
4445 page.items.iter().any(|item| {
4446 matches!(
4447 item,
4448 SettingsPageItem::SubPageLink(link)
4449 if link.json_path == Some(AGENT_SKILLS_SETTINGS_PATH)
4450 )
4451 })
4452 });
4453 if let Some(page_index) = skills_page_index
4454 && let Some(navbar_entry_index) = self
4455 .navbar_entries
4456 .iter()
4457 .position(|entry| entry.page_index == page_index && entry.is_root)
4458 {
4459 self.open_navbar_entry_page(navbar_entry_index);
4460 }
4461 self.navigate_to_sub_page(AGENT_SKILLS_SETTINGS_PATH, window, cx);
4462 self.open_skill_creator_sub_page(open_mode, window, cx);
4463 }
4464
4465 /// Navigate to a sub-page by its json_path.
4466 /// Returns true if the sub-page was found and pushed, false otherwise.
4467 pub fn navigate_to_sub_page(
4468 &mut self,
4469 json_path: &str,
4470 window: &mut Window,
4471 cx: &mut Context<SettingsWindow>,
4472 ) -> bool {
4473 for page in &self.pages {
4474 for (item_index, item) in page.items.iter().enumerate() {
4475 if let SettingsPageItem::SubPageLink(sub_page_link) = item {
4476 if sub_page_link.json_path == Some(json_path) {
4477 let section_header = page
4478 .items
4479 .iter()
4480 .take(item_index)
4481 .rev()
4482 .find_map(|item| item.header_text().map(SharedString::new_static))
4483 .unwrap_or_else(|| "Settings".into());
4484
4485 self.push_sub_page(sub_page_link.clone(), section_header, window, cx);
4486 return true;
4487 }
4488 }
4489 }
4490 }
4491 false
4492 }
4493
4494 /// Navigate to a setting by its json_path.
4495 /// Clears the sub-page stack and scrolls to the setting item.
4496 /// Returns true if the setting was found, false otherwise.
4497 pub fn navigate_to_setting(
4498 &mut self,
4499 json_path: &str,
4500 window: &mut Window,
4501 cx: &mut Context<SettingsWindow>,
4502 ) -> bool {
4503 self.sub_page_stack.clear();
4504
4505 for (page_index, page) in self.pages.iter().enumerate() {
4506 for (item_index, item) in page.items.iter().enumerate() {
4507 let item_json_path = match item {
4508 SettingsPageItem::SettingItem(setting_item) => setting_item.field.json_path(),
4509 SettingsPageItem::DynamicItem(dynamic_item) => {
4510 dynamic_item.discriminant.field.json_path()
4511 }
4512 _ => None,
4513 };
4514 if item_json_path == Some(json_path) {
4515 if let Some(navbar_entry_index) = self
4516 .navbar_entries
4517 .iter()
4518 .position(|e| e.page_index == page_index && e.is_root)
4519 {
4520 self.open_and_scroll_to_navbar_entry(
4521 navbar_entry_index,
4522 None,
4523 false,
4524 window,
4525 cx,
4526 );
4527 self.scroll_to_content_item(item_index, window, cx);
4528 return true;
4529 }
4530 }
4531 }
4532 }
4533 false
4534 }
4535
4536 pub(crate) fn pop_sub_page(&mut self, window: &mut Window, cx: &mut Context<SettingsWindow>) {
4537 self.regex_validation_error = None;
4538 self.sandbox_host_validation_error = None;
4539 if let Some(popped) = self.sub_page_stack.pop()
4540 && popped.link.r#type == SubPageType::SkillCreator
4541 {
4542 self.skill_creator_page = None;
4543 }
4544 self.content_focus_handle.focus_handle(cx).focus(window, cx);
4545 cx.notify();
4546 }
4547
4548 fn focus_file_at_index(&mut self, index: usize, window: &mut Window, cx: &mut App) {
4549 if let Some((_, handle)) = self.files.get(index) {
4550 handle.focus(window, cx);
4551 }
4552 }
4553
4554 fn focused_file_index(&self, window: &Window, cx: &Context<Self>) -> usize {
4555 if self.files_focus_handle.contains_focused(window, cx)
4556 && let Some(index) = self
4557 .files
4558 .iter()
4559 .position(|(_, handle)| handle.is_focused(window))
4560 {
4561 return index;
4562 }
4563 if let Some(current_file_index) = self
4564 .files
4565 .iter()
4566 .position(|(file, _)| file == &self.current_file)
4567 {
4568 return current_file_index;
4569 }
4570 0
4571 }
4572
4573 fn focus_handle_for_content_element(
4574 &self,
4575 actual_item_index: usize,
4576 cx: &Context<Self>,
4577 ) -> FocusHandle {
4578 let page_index = self.current_page_index();
4579 self.content_handles[page_index][actual_item_index].focus_handle(cx)
4580 }
4581
4582 fn focused_nav_entry(&self, window: &Window, cx: &App) -> Option<usize> {
4583 if !self
4584 .navbar_focus_handle
4585 .focus_handle(cx)
4586 .contains_focused(window, cx)
4587 {
4588 return None;
4589 }
4590 for (index, entry) in self.navbar_entries.iter().enumerate() {
4591 if entry.focus_handle.is_focused(window) {
4592 return Some(index);
4593 }
4594 }
4595 None
4596 }
4597
4598 fn root_entry_containing(&self, nav_entry_index: usize) -> usize {
4599 let mut index = Some(nav_entry_index);
4600 while let Some(prev_index) = index
4601 && !self.navbar_entries[prev_index].is_root
4602 {
4603 index = prev_index.checked_sub(1);
4604 }
4605 return index.expect("No root entry found");
4606 }
4607}
4608
4609impl Render for SettingsWindow {
4610 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
4611 let ui_font = theme_settings::setup_ui_font(window, cx);
4612
4613 client_side_decorations(
4614 v_flex()
4615 .text_color(cx.theme().colors().text)
4616 .size_full()
4617 .children(self.title_bar.clone())
4618 .child(
4619 div()
4620 .id("settings-window")
4621 .key_context("SettingsWindow")
4622 .track_focus(&self.focus_handle)
4623 .on_action(cx.listener(|this, _: &OpenCurrentFile, window, cx| {
4624 this.open_current_settings_file(window, cx);
4625 }))
4626 .on_action(|_: &Minimize, window, _cx| {
4627 window.minimize_window();
4628 })
4629 .on_action(cx.listener(|this, _: &search::FocusSearch, window, cx| {
4630 this.search_bar.focus_handle(cx).focus(window, cx);
4631 }))
4632 .on_action(cx.listener(|this, _: &ToggleFocusNav, window, cx| {
4633 if this
4634 .navbar_focus_handle
4635 .focus_handle(cx)
4636 .contains_focused(window, cx)
4637 {
4638 this.open_and_scroll_to_navbar_entry(
4639 this.navbar_entry,
4640 None,
4641 true,
4642 window,
4643 cx,
4644 );
4645 } else {
4646 this.focus_and_scroll_to_nav_entry(this.navbar_entry, window, cx);
4647 }
4648 }))
4649 .on_action(cx.listener(
4650 |this, FocusFile(file_index): &FocusFile, window, cx| {
4651 this.focus_file_at_index(*file_index as usize, window, cx);
4652 },
4653 ))
4654 .on_action(cx.listener(|this, _: &FocusNextFile, window, cx| {
4655 let next_index = usize::min(
4656 this.focused_file_index(window, cx) + 1,
4657 this.files.len().saturating_sub(1),
4658 );
4659 this.focus_file_at_index(next_index, window, cx);
4660 }))
4661 .on_action(cx.listener(|this, _: &FocusPreviousFile, window, cx| {
4662 let prev_index = this.focused_file_index(window, cx).saturating_sub(1);
4663 this.focus_file_at_index(prev_index, window, cx);
4664 }))
4665 .on_action(cx.listener(|this, _: &menu::SelectNext, window, cx| {
4666 if this
4667 .search_bar
4668 .focus_handle(cx)
4669 .contains_focused(window, cx)
4670 {
4671 this.focus_and_scroll_to_first_visible_nav_entry(window, cx);
4672 } else {
4673 window.focus_next(cx);
4674 }
4675 }))
4676 .on_action(|_: &menu::SelectPrevious, window, cx| {
4677 window.focus_prev(cx);
4678 })
4679 .flex()
4680 .flex_row()
4681 .flex_1()
4682 .min_h_0()
4683 .font(ui_font)
4684 .bg(cx.theme().colors().background)
4685 .text_color(cx.theme().colors().text)
4686 .when(!cfg!(target_os = "macos"), |this| {
4687 this.border_t_1().border_color(cx.theme().colors().border)
4688 })
4689 .child(self.render_nav(window, cx))
4690 .child(self.render_page(window, cx)),
4691 ),
4692 window,
4693 cx,
4694 )
4695 }
4696}
4697
4698pub(crate) fn all_projects(
4699 window: Option<&WindowHandle<MultiWorkspace>>,
4700 cx: &App,
4701) -> impl Iterator<Item = Entity<Project>> {
4702 let mut seen_project_ids = std::collections::HashSet::new();
4703 let app_state = workspace::AppState::global(cx);
4704 app_state
4705 .workspace_store
4706 .read(cx)
4707 .workspaces()
4708 .filter_map(|weak| weak.upgrade())
4709 .map(|workspace: Entity<Workspace>| workspace.read(cx).project().clone())
4710 .chain(
4711 window
4712 .and_then(|handle| handle.read(cx).ok())
4713 .into_iter()
4714 .flat_map(|multi_workspace| {
4715 multi_workspace
4716 .workspaces()
4717 .map(|workspace| workspace.read(cx).project().clone())
4718 .collect::<Vec<_>>()
4719 }),
4720 )
4721 .filter(move |project| seen_project_ids.insert(project.entity_id()))
4722}
4723
4724fn open_user_settings_in_workspace(
4725 workspace: &mut Workspace,
4726 window: &mut Window,
4727 cx: &mut Context<Workspace>,
4728) {
4729 let project = workspace.project().clone();
4730
4731 cx.spawn_in(window, async move |workspace, cx| {
4732 let (config_dir, settings_file) = project.update(cx, |project, cx| {
4733 (
4734 project.try_windows_path_to_wsl(paths::config_dir().as_path(), cx),
4735 project.try_windows_path_to_wsl(paths::settings_file().as_path(), cx),
4736 )
4737 });
4738 let config_dir = config_dir.await?;
4739 let settings_file = settings_file.await?;
4740 project
4741 .update(cx, |project, cx| {
4742 project.find_or_create_worktree(&config_dir, false, cx)
4743 })
4744 .await
4745 .ok();
4746 workspace
4747 .update_in(cx, |workspace, window, cx| {
4748 workspace.open_paths(
4749 vec![settings_file],
4750 OpenOptions {
4751 visible: Some(OpenVisible::None),
4752 ..Default::default()
4753 },
4754 None,
4755 window,
4756 cx,
4757 )
4758 })?
4759 .await;
4760
4761 workspace.update_in(cx, |_, window, cx| {
4762 window.activate_window();
4763 cx.notify();
4764 })
4765 })
4766 .detach();
4767}
4768
4769fn update_settings_file(
4770 file: SettingsUiFile,
4771 file_name: Option<&'static str>,
4772 window: &mut Window,
4773 cx: &mut App,
4774 update: impl 'static + Send + FnOnce(&mut SettingsContent, &App),
4775) -> Result<()> {
4776 telemetry::event!("Settings Change", setting = file_name, type = file.setting_type());
4777
4778 match file {
4779 SettingsUiFile::Project((worktree_id, rel_path)) => {
4780 let rel_path = rel_path.join(paths::local_settings_file_relative_path());
4781 let Some(settings_window) = window.root::<SettingsWindow>().flatten() else {
4782 anyhow::bail!("No settings window found");
4783 };
4784
4785 update_project_setting_file(worktree_id, rel_path.into(), update, settings_window, cx)
4786 }
4787 SettingsUiFile::User => {
4788 // todo(settings_ui) error?
4789 SettingsStore::global(cx).update_settings_file(<dyn fs::Fs>::global(cx), update);
4790 Ok(())
4791 }
4792 SettingsUiFile::Server(_) => unimplemented!(),
4793 }
4794}
4795
4796struct ProjectSettingsUpdateEntry {
4797 worktree_id: WorktreeId,
4798 rel_path: Arc<RelPath>,
4799 settings_window: WeakEntity<SettingsWindow>,
4800 project: WeakEntity<Project>,
4801 worktree: WeakEntity<Worktree>,
4802 update: Box<dyn FnOnce(&mut SettingsContent, &App)>,
4803}
4804
4805struct ProjectSettingsUpdateQueue {
4806 tx: mpsc::UnboundedSender<ProjectSettingsUpdateEntry>,
4807 _task: Task<()>,
4808}
4809
4810impl Global for ProjectSettingsUpdateQueue {}
4811
4812impl ProjectSettingsUpdateQueue {
4813 fn new(cx: &mut App) -> Self {
4814 let (tx, mut rx) = mpsc::unbounded();
4815 let task = cx.spawn(async move |mut cx| {
4816 while let Some(entry) = rx.next().await {
4817 if let Err(err) = Self::process_entry(entry, &mut cx).await {
4818 log::error!("Failed to update project settings: {err:?}");
4819 }
4820 }
4821 });
4822 Self { tx, _task: task }
4823 }
4824
4825 fn enqueue(cx: &mut App, entry: ProjectSettingsUpdateEntry) {
4826 cx.update_global::<Self, _>(|queue, _cx| {
4827 if let Err(err) = queue.tx.unbounded_send(entry) {
4828 log::error!("Failed to enqueue project settings update: {err}");
4829 }
4830 });
4831 }
4832
4833 async fn process_entry(entry: ProjectSettingsUpdateEntry, cx: &mut AsyncApp) -> Result<()> {
4834 let ProjectSettingsUpdateEntry {
4835 worktree_id,
4836 rel_path,
4837 settings_window,
4838 project,
4839 worktree,
4840 update,
4841 } = entry;
4842
4843 let project_path = ProjectPath {
4844 worktree_id,
4845 path: rel_path.clone(),
4846 };
4847
4848 let needs_creation = worktree.read_with(cx, |worktree, _| {
4849 worktree.entry_for_path(&rel_path).is_none()
4850 })?;
4851
4852 if needs_creation {
4853 worktree
4854 .update(cx, |worktree, cx| {
4855 worktree.create_entry(rel_path.clone(), false, None, cx)
4856 })?
4857 .await?;
4858 }
4859
4860 let buffer_store = project.read_with(cx, |project, _cx| project.buffer_store().clone())?;
4861
4862 let cached_buffer = settings_window
4863 .read_with(cx, |settings_window, _| {
4864 settings_window
4865 .project_setting_file_buffers
4866 .get(&project_path)
4867 .cloned()
4868 })
4869 .unwrap_or_default();
4870
4871 let buffer = if let Some(cached_buffer) = cached_buffer {
4872 let needs_reload = cached_buffer.read_with(cx, |buffer, _| buffer.has_conflict());
4873 if needs_reload {
4874 cached_buffer
4875 .update(cx, |buffer, cx| buffer.reload(cx))
4876 .await
4877 .context("Failed to reload settings file")?;
4878 }
4879 cached_buffer
4880 } else {
4881 let buffer = buffer_store
4882 .update(cx, |store, cx| store.open_buffer(project_path.clone(), cx))
4883 .await
4884 .context("Failed to open settings file")?;
4885
4886 let _ = settings_window.update(cx, |this, _cx| {
4887 this.project_setting_file_buffers
4888 .insert(project_path, buffer.clone());
4889 });
4890
4891 buffer
4892 };
4893
4894 buffer.update(cx, |buffer, cx| {
4895 let current_text = buffer.text();
4896 if let Some(new_text) = cx
4897 .global::<SettingsStore>()
4898 .new_text_for_update(current_text, |settings| update(settings, cx))
4899 .log_err()
4900 {
4901 buffer.edit([(0..buffer.len(), new_text)], None, cx);
4902 }
4903 });
4904
4905 buffer_store
4906 .update(cx, |store, cx| store.save_buffer(buffer, cx))
4907 .await
4908 .context("Failed to save settings file")?;
4909
4910 Ok(())
4911 }
4912}
4913
4914fn update_project_setting_file(
4915 worktree_id: WorktreeId,
4916 rel_path: Arc<RelPath>,
4917 update: impl 'static + FnOnce(&mut SettingsContent, &App),
4918 settings_window: Entity<SettingsWindow>,
4919 cx: &mut App,
4920) -> Result<()> {
4921 let Some((worktree, project)) =
4922 all_projects(settings_window.read(cx).original_window.as_ref(), cx).find_map(|project| {
4923 project
4924 .read(cx)
4925 .worktree_for_id(worktree_id, cx)
4926 .zip(Some(project))
4927 })
4928 else {
4929 anyhow::bail!("Could not find project with worktree id: {}", worktree_id);
4930 };
4931
4932 let entry = ProjectSettingsUpdateEntry {
4933 worktree_id,
4934 rel_path,
4935 settings_window: settings_window.downgrade(),
4936 project: project.downgrade(),
4937 worktree: worktree.downgrade(),
4938 update: Box::new(update),
4939 };
4940
4941 ProjectSettingsUpdateQueue::enqueue(cx, entry);
4942
4943 Ok(())
4944}
4945
4946/// Derives a human-readable label for assistive technology from a setting's
4947/// JSON path, e.g. `"buffer_font_size"` becomes `"Buffer Font Size"`.
4948struct CurrentSettingsValue<'a, T> {
4949 value: &'a T,
4950 disabled: bool,
4951}
4952
4953fn get_current_value<'a, T>(
4954 settings_store: &'a SettingsStore,
4955 file: &SettingsUiFile,
4956 field: &'a SettingField<T>,
4957 cx: &'a App,
4958) -> Option<CurrentSettingsValue<'a, T>> {
4959 let user_store = AppState::global(cx).user_store.read(cx);
4960 let org_config = user_store.current_organization_configuration();
4961
4962 let (_file, value) = settings_store.get_value_from_file(file.to_settings(), field.pick);
4963 let value = value?;
4964
4965 let org_value = org_config
4966 .zip(field.organization_override)
4967 .and_then(|(org_config, org_override)| (org_override)(org_config));
4968
4969 Some(CurrentSettingsValue {
4970 disabled: org_value.is_some(),
4971 value: org_value.unwrap_or(&value),
4972 })
4973}
4974
4975fn render_text_field<T: From<String> + Into<String> + AsRef<str> + Clone>(
4976 field: SettingField<T>,
4977 file: SettingsUiFile,
4978 metadata: Option<&SettingsFieldMetadata>,
4979 title: &'static str,
4980 description: &'static str,
4981 _window: &mut Window,
4982 cx: &mut App,
4983) -> AnyElement {
4984 let (_, initial_text) =
4985 SettingsStore::global(cx).get_value_from_file(file.to_settings(), field.pick);
4986 let initial_text = if metadata.is_some_and(|metadata| metadata.treat_missing_text_as_empty) {
4987 Some(
4988 initial_text
4989 .map(|text| text.as_ref().to_string())
4990 .unwrap_or_default(),
4991 )
4992 } else {
4993 initial_text
4994 .filter(|text| !text.as_ref().is_empty())
4995 .map(|text| text.as_ref().to_string())
4996 };
4997
4998 // The JSON path uniquely identifies the setting this field edits, making
4999 // it a stable, collision-free element ID within the page.
5000 SettingsInputField::new(field.json_path.unwrap_or("settings-text-field"))
5001 .tab_index(0)
5002 .aria_label(title)
5003 .when(!description.is_empty(), |editor| {
5004 editor.aria_description(description)
5005 })
5006 .when_some(initial_text, |editor, text| editor.with_initial_text(text))
5007 .when_some(
5008 metadata.and_then(|metadata| metadata.placeholder),
5009 |editor, placeholder| editor.with_placeholder(placeholder),
5010 )
5011 .when(
5012 metadata.is_some_and(|metadata| metadata.display_confirm_button),
5013 |editor| editor.display_confirm_button(),
5014 )
5015 .when(
5016 metadata.is_some_and(|metadata| metadata.display_clear_button),
5017 |editor| editor.display_clear_button(),
5018 )
5019 .when(
5020 metadata.is_some_and(|metadata| metadata.confirm_on_focus_out),
5021 |editor| editor.confirm_on_focus_out(),
5022 )
5023 .on_confirm({
5024 move |new_text, window, cx| {
5025 update_settings_file(
5026 file.clone(),
5027 field.json_path,
5028 window,
5029 cx,
5030 move |settings, app| {
5031 (field.write)(settings, new_text.map(Into::into), app);
5032 },
5033 )
5034 .log_err(); // todo(settings_ui) don't log err
5035 }
5036 })
5037 .into_any_element()
5038}
5039
5040fn render_toggle_button<B: Into<bool> + From<bool> + Copy>(
5041 field: SettingField<B>,
5042 file: SettingsUiFile,
5043 _metadata: Option<&SettingsFieldMetadata>,
5044 title: &'static str,
5045 description: &'static str,
5046 _window: &mut Window,
5047 cx: &mut App,
5048) -> AnyElement {
5049 let value = get_current_value(&SettingsStore::global(cx), &file, &field, cx);
5050 let (value, disabled) = value
5051 .map(|current_value| (*current_value.value, current_value.disabled))
5052 .unwrap_or((false.into(), false));
5053
5054 let toggle_state = if value.into() {
5055 ToggleState::Selected
5056 } else {
5057 ToggleState::Unselected
5058 };
5059
5060 Switch::new("toggle_button", toggle_state)
5061 .tab_index(0_isize)
5062 .aria_label(title)
5063 .when(!description.is_empty(), |this| {
5064 this.aria_description(description)
5065 })
5066 .disabled(disabled)
5067 .on_click({
5068 move |state, window, cx| {
5069 telemetry::event!("Settings Change", setting = field.json_path, type = file.setting_type());
5070
5071 let state = *state == ui::ToggleState::Selected;
5072 update_settings_file(file.clone(), field.json_path, window, cx, move |settings, app| {
5073 (field.write)(settings, Some(state.into()), app);
5074 })
5075 .log_err(); // todo(settings_ui) don't log err
5076 }
5077 })
5078 .into_any_element()
5079}
5080
5081fn render_editable_number_field<T: NumberFieldType + Send + Sync>(
5082 field: SettingField<T>,
5083 file: SettingsUiFile,
5084 _metadata: Option<&SettingsFieldMetadata>,
5085 title: &'static str,
5086 description: &'static str,
5087 window: &mut Window,
5088 cx: &mut App,
5089) -> AnyElement {
5090 let (_, value) = SettingsStore::global(cx).get_value_from_file(file.to_settings(), field.pick);
5091 let value = value.copied().unwrap_or_else(T::min_value);
5092
5093 let id = field
5094 .json_path
5095 .map(|p| format!("numeric_stepper_{}", p))
5096 .unwrap_or_else(|| "numeric_stepper".to_string());
5097
5098 NumberField::new(id, value, window, cx)
5099 .mode(NumberFieldMode::Edit, cx)
5100 .tab_index(0_isize)
5101 .aria_label(title)
5102 .when(!description.is_empty(), |this| {
5103 this.aria_description(description)
5104 })
5105 .on_change({
5106 move |value, window, cx| {
5107 let value = *value;
5108 update_settings_file(
5109 file.clone(),
5110 field.json_path,
5111 window,
5112 cx,
5113 move |settings, app| {
5114 (field.write)(settings, Some(value), app);
5115 },
5116 )
5117 .log_err(); // todo(settings_ui) don't log err
5118 }
5119 })
5120 .into_any_element()
5121}
5122
5123fn render_dropdown<T>(
5124 field: SettingField<T>,
5125 file: SettingsUiFile,
5126 metadata: Option<&SettingsFieldMetadata>,
5127 title: &'static str,
5128 description: &'static str,
5129 _window: &mut Window,
5130 cx: &mut App,
5131) -> AnyElement
5132where
5133 T: strum::VariantArray + strum::VariantNames + Copy + PartialEq + Send + Sync + 'static,
5134{
5135 let variants = || -> &'static [T] { <T as strum::VariantArray>::VARIANTS };
5136 let labels = || -> &'static [&'static str] { <T as strum::VariantNames>::VARIANTS };
5137 let should_do_titlecase = metadata
5138 .and_then(|metadata| metadata.should_do_titlecase)
5139 .unwrap_or(true);
5140
5141 let current_value = get_current_value(&SettingsStore::global(cx), &file, &field, cx);
5142 let (current_value, disabled) = current_value
5143 .map(|current_value| (*current_value.value, current_value.disabled))
5144 .unwrap_or((variants()[0], false));
5145
5146 EnumVariantDropdown::new("dropdown", current_value, variants(), labels(), {
5147 move |value, window, cx| {
5148 if value == current_value {
5149 return;
5150 }
5151 update_settings_file(
5152 file.clone(),
5153 field.json_path,
5154 window,
5155 cx,
5156 move |settings, app| {
5157 (field.write)(settings, Some(value), app);
5158 },
5159 )
5160 .log_err(); // todo(settings_ui) don't log err
5161 }
5162 })
5163 .aria_label(title)
5164 .when(!description.is_empty(), |this| {
5165 this.aria_description(description)
5166 })
5167 .disabled(disabled)
5168 .tab_index(0)
5169 .title_case(should_do_titlecase)
5170 .into_any_element()
5171}
5172
5173fn render_picker_trigger_button(id: SharedString, label: SharedString) -> Button {
5174 Button::new(id, label)
5175 .aria_role(Role::ComboBox)
5176 .tab_index(0_isize)
5177 .style(ButtonStyle::Outlined)
5178 .size(ButtonSize::Medium)
5179 .end_icon(
5180 Icon::new(IconName::ChevronUpDown)
5181 .size(IconSize::Small)
5182 .color(Color::Muted),
5183 )
5184}
5185
5186/// Wires the Expand/Collapse accessibility actions on a picker trigger button to
5187/// the popover handle, so assistive technology can open and close the picker
5188/// (used by UIA on Windows and AX on macOS; Linux/AT-SPI uses the click action).
5189fn wire_picker_trigger_a11y<M: gpui::ManagedView>(
5190 button: Button,
5191 handle: ui::PopoverMenuHandle<M>,
5192) -> Button {
5193 let show_handle = handle.clone();
5194 let hide_handle = handle;
5195 button
5196 .on_a11y_action(gpui::accesskit::Action::Expand, move |_, window, cx| {
5197 show_handle.show(window, cx);
5198 })
5199 .on_a11y_action(gpui::accesskit::Action::Collapse, move |_, _window, cx| {
5200 hide_handle.hide(cx);
5201 })
5202}
5203
5204fn render_font_picker(
5205 field: SettingField<settings::FontFamilyName>,
5206 file: SettingsUiFile,
5207 _metadata: Option<&SettingsFieldMetadata>,
5208 title: &'static str,
5209 description: &'static str,
5210 _window: &mut Window,
5211 cx: &mut App,
5212) -> AnyElement {
5213 let current_value = SettingsStore::global(cx)
5214 .get_value_from_file(file.to_settings(), field.pick)
5215 .1
5216 .cloned()
5217 .map_or_else(|| SharedString::default(), |value| value.into_gpui());
5218
5219 let handle = ui::PopoverMenuHandle::default();
5220 PopoverMenu::new("font-picker")
5221 .trigger(wire_picker_trigger_a11y(
5222 render_picker_trigger_button(
5223 "font_family_picker_trigger".into(),
5224 current_value.clone(),
5225 )
5226 .aria_label(title)
5227 .when(!description.is_empty(), |this| {
5228 this.aria_description(description)
5229 }),
5230 handle.clone(),
5231 ))
5232 .menu(move |window, cx| {
5233 let file = file.clone();
5234 let current_value = current_value.clone();
5235
5236 Some(cx.new(move |cx| {
5237 font_picker(
5238 current_value,
5239 move |font_name, window, cx| {
5240 update_settings_file(
5241 file.clone(),
5242 field.json_path,
5243 window,
5244 cx,
5245 move |settings, app| {
5246 (field.write)(settings, Some(font_name.to_string().into()), app);
5247 },
5248 )
5249 .log_err(); // todo(settings_ui) don't log err
5250 },
5251 window,
5252 cx,
5253 )
5254 }))
5255 })
5256 .anchor(gpui::Anchor::TopLeft)
5257 .offset(gpui::Point {
5258 x: px(0.0),
5259 y: px(2.0),
5260 })
5261 .with_handle(handle)
5262 .into_any_element()
5263}
5264
5265fn render_theme_picker(
5266 field: SettingField<settings::ThemeName>,
5267 file: SettingsUiFile,
5268 _metadata: Option<&SettingsFieldMetadata>,
5269 title: &'static str,
5270 description: &'static str,
5271 _window: &mut Window,
5272 cx: &mut App,
5273) -> AnyElement {
5274 let (_, value) = SettingsStore::global(cx).get_value_from_file(file.to_settings(), field.pick);
5275 let current_value = value
5276 .cloned()
5277 .map(|theme_name| theme_name.0.into())
5278 .unwrap_or_else(|| cx.theme().name.clone());
5279
5280 let handle = ui::PopoverMenuHandle::default();
5281 PopoverMenu::new("theme-picker")
5282 .trigger(wire_picker_trigger_a11y(
5283 render_picker_trigger_button("theme_picker_trigger".into(), current_value.clone())
5284 .aria_label(title)
5285 .when(!description.is_empty(), |this| {
5286 this.aria_description(description)
5287 }),
5288 handle.clone(),
5289 ))
5290 .menu(move |window, cx| {
5291 Some(cx.new(|cx| {
5292 let file = file.clone();
5293 let current_value = current_value.clone();
5294 theme_picker(
5295 current_value,
5296 move |theme_name, window, cx| {
5297 update_settings_file(
5298 file.clone(),
5299 field.json_path,
5300 window,
5301 cx,
5302 move |settings, app| {
5303 (field.write)(
5304 settings,
5305 Some(settings::ThemeName(theme_name.into())),
5306 app,
5307 );
5308 },
5309 )
5310 .log_err(); // todo(settings_ui) don't log err
5311 },
5312 window,
5313 cx,
5314 )
5315 }))
5316 })
5317 .anchor(gpui::Anchor::TopLeft)
5318 .offset(gpui::Point {
5319 x: px(0.0),
5320 y: px(2.0),
5321 })
5322 .with_handle(handle)
5323 .into_any_element()
5324}
5325
5326fn render_icon_theme_picker(
5327 field: SettingField<settings::IconThemeName>,
5328 file: SettingsUiFile,
5329 _metadata: Option<&SettingsFieldMetadata>,
5330 title: &'static str,
5331 description: &'static str,
5332 _window: &mut Window,
5333 cx: &mut App,
5334) -> AnyElement {
5335 let (_, value) = SettingsStore::global(cx).get_value_from_file(file.to_settings(), field.pick);
5336 let current_value = value
5337 .cloned()
5338 .map(|theme_name| theme_name.0.into())
5339 .unwrap_or_else(|| cx.theme().name.clone());
5340
5341 let handle = ui::PopoverMenuHandle::default();
5342 PopoverMenu::new("icon-theme-picker")
5343 .trigger(wire_picker_trigger_a11y(
5344 render_picker_trigger_button("icon_theme_picker_trigger".into(), current_value.clone())
5345 .aria_label(title)
5346 .when(!description.is_empty(), |this| {
5347 this.aria_description(description)
5348 }),
5349 handle.clone(),
5350 ))
5351 .menu(move |window, cx| {
5352 Some(cx.new(|cx| {
5353 let file = file.clone();
5354 let current_value = current_value.clone();
5355 icon_theme_picker(
5356 current_value,
5357 move |theme_name, window, cx| {
5358 update_settings_file(
5359 file.clone(),
5360 field.json_path,
5361 window,
5362 cx,
5363 move |settings, app| {
5364 (field.write)(
5365 settings,
5366 Some(settings::IconThemeName(theme_name.into())),
5367 app,
5368 );
5369 },
5370 )
5371 .log_err(); // todo(settings_ui) don't log err
5372 },
5373 window,
5374 cx,
5375 )
5376 }))
5377 })
5378 .anchor(gpui::Anchor::TopLeft)
5379 .offset(gpui::Point {
5380 x: px(0.0),
5381 y: px(2.0),
5382 })
5383 .with_handle(handle)
5384 .into_any_element()
5385}
5386
5387#[cfg(test)]
5388pub mod test {
5389
5390 use super::*;
5391
5392 impl SettingsWindow {
5393 fn navbar_entry(&self) -> usize {
5394 self.navbar_entry
5395 }
5396
5397 #[cfg(any(test, feature = "test-support"))]
5398 pub fn test(window: &mut Window, cx: &mut Context<Self>) -> Self {
5399 let search_bar = cx.new(|cx| Editor::single_line(window, cx));
5400 let dummy_page = SettingsPage {
5401 title: "Test",
5402 items: Box::new([]),
5403 };
5404 Self {
5405 title_bar: None,
5406 original_window: None,
5407 worktree_root_dirs: HashMap::default(),
5408 files: Vec::default(),
5409 current_file: SettingsUiFile::User,
5410 project_setting_file_buffers: HashMap::default(),
5411 pages: vec![dummy_page],
5412 search_bar,
5413 navbar_entry: 0,
5414 navbar_entries: Vec::default(),
5415 navbar_scroll_handle: UniformListScrollHandle::default(),
5416 navbar_focus_subscriptions: Vec::default(),
5417 filter_table: Vec::default(),
5418 has_query: false,
5419 content_handles: Vec::default(),
5420 search_task: None,
5421 sub_page_stack: Vec::default(),
5422 opening_link: false,
5423 focus_handle: cx.focus_handle(),
5424 navbar_focus_handle: NonFocusableHandle::new(
5425 NAVBAR_CONTAINER_TAB_INDEX,
5426 false,
5427 window,
5428 cx,
5429 ),
5430 content_focus_handle: NonFocusableHandle::new(
5431 CONTENT_CONTAINER_TAB_INDEX,
5432 false,
5433 window,
5434 cx,
5435 ),
5436 files_focus_handle: cx.focus_handle(),
5437 search_index: None,
5438 list_state: ListState::new(0, gpui::ListAlignment::Top, px(0.0)),
5439 shown_errors: HashSet::default(),
5440 hidden_deleted_skill_directory_paths: HashSet::default(),
5441 regex_validation_error: None,
5442 sandbox_host_validation_error: None,
5443 last_copied_link_path: None,
5444 provider_configuration_views: HashMap::default(),
5445 configuring_provider: None,
5446 last_copied_skill_directory_path: None,
5447 llm_provider_form: None,
5448 llm_provider_add_focus_handle: cx.focus_handle(),
5449 mcp_server_form: None,
5450 mcp_add_server_focus_handle: cx.focus_handle(),
5451 custom_agent_form: None,
5452 external_agent_add_focus_handle: cx.focus_handle(),
5453 harness_maintenance: Default::default(),
5454 harness_maintenance_task: None,
5455 harness_maintenance_error: None,
5456 skill_creator_page: None,
5457 }
5458 }
5459 }
5460
5461 impl PartialEq for NavBarEntry {
5462 fn eq(&self, other: &Self) -> bool {
5463 self.title == other.title
5464 && self.is_root == other.is_root
5465 && self.expanded == other.expanded
5466 && self.page_index == other.page_index
5467 && self.item_index == other.item_index
5468 // ignoring focus_handle
5469 }
5470 }
5471
5472 pub fn register_settings(cx: &mut App) {
5473 settings::init(cx);
5474 theme_settings::init(theme::LoadThemes::JustBase, cx);
5475 editor::init(cx);
5476 menu::init();
5477 language_model::init(cx);
5478 }
5479
5480 fn parse(input: &'static str, window: &mut Window, cx: &mut App) -> SettingsWindow {
5481 struct PageBuilder {
5482 title: &'static str,
5483 items: Vec<SettingsPageItem>,
5484 }
5485 let mut page_builders: Vec<PageBuilder> = Vec::new();
5486 let mut expanded_pages = Vec::new();
5487 let mut selected_idx = None;
5488 let mut index = 0;
5489 let mut in_expanded_section = false;
5490
5491 for mut line in input
5492 .lines()
5493 .map(|line| line.trim())
5494 .filter(|line| !line.is_empty())
5495 {
5496 if let Some(pre) = line.strip_suffix('*') {
5497 assert!(selected_idx.is_none(), "Only one selected entry allowed");
5498 selected_idx = Some(index);
5499 line = pre;
5500 }
5501 let (kind, title) = line.split_once(" ").unwrap();
5502 assert_eq!(kind.len(), 1);
5503 let kind = kind.chars().next().unwrap();
5504 if kind == 'v' {
5505 let page_idx = page_builders.len();
5506 expanded_pages.push(page_idx);
5507 page_builders.push(PageBuilder {
5508 title,
5509 items: vec![],
5510 });
5511 index += 1;
5512 in_expanded_section = true;
5513 } else if kind == '>' {
5514 page_builders.push(PageBuilder {
5515 title,
5516 items: vec![],
5517 });
5518 index += 1;
5519 in_expanded_section = false;
5520 } else if kind == '-' {
5521 page_builders
5522 .last_mut()
5523 .unwrap()
5524 .items
5525 .push(SettingsPageItem::SectionHeader(title));
5526 if selected_idx == Some(index) && !in_expanded_section {
5527 panic!("Items in unexpanded sections cannot be selected");
5528 }
5529 index += 1;
5530 } else {
5531 panic!(
5532 "Entries must start with one of 'v', '>', or '-'\n line: {}",
5533 line
5534 );
5535 }
5536 }
5537
5538 let pages: Vec<SettingsPage> = page_builders
5539 .into_iter()
5540 .map(|builder| SettingsPage {
5541 title: builder.title,
5542 items: builder.items.into_boxed_slice(),
5543 })
5544 .collect();
5545
5546 let mut settings_window = SettingsWindow {
5547 title_bar: None,
5548 original_window: None,
5549 worktree_root_dirs: HashMap::default(),
5550 files: Vec::default(),
5551 current_file: crate::SettingsUiFile::User,
5552 project_setting_file_buffers: HashMap::default(),
5553 pages,
5554 search_bar: cx.new(|cx| Editor::single_line(window, cx)),
5555 navbar_entry: selected_idx.expect("Must have a selected navbar entry"),
5556 navbar_entries: Vec::default(),
5557 navbar_scroll_handle: UniformListScrollHandle::default(),
5558 navbar_focus_subscriptions: vec![],
5559 filter_table: vec![],
5560 sub_page_stack: vec![],
5561 opening_link: false,
5562 has_query: false,
5563 content_handles: vec![],
5564 search_task: None,
5565 focus_handle: cx.focus_handle(),
5566 navbar_focus_handle: NonFocusableHandle::new(
5567 NAVBAR_CONTAINER_TAB_INDEX,
5568 false,
5569 window,
5570 cx,
5571 ),
5572 content_focus_handle: NonFocusableHandle::new(
5573 CONTENT_CONTAINER_TAB_INDEX,
5574 false,
5575 window,
5576 cx,
5577 ),
5578 files_focus_handle: cx.focus_handle(),
5579 search_index: None,
5580 list_state: ListState::new(0, gpui::ListAlignment::Top, px(0.0)),
5581 shown_errors: HashSet::default(),
5582 hidden_deleted_skill_directory_paths: HashSet::default(),
5583 regex_validation_error: None,
5584 sandbox_host_validation_error: None,
5585 last_copied_link_path: None,
5586 provider_configuration_views: HashMap::default(),
5587 configuring_provider: None,
5588 last_copied_skill_directory_path: None,
5589 llm_provider_form: None,
5590 llm_provider_add_focus_handle: cx.focus_handle(),
5591 mcp_server_form: None,
5592 mcp_add_server_focus_handle: cx.focus_handle(),
5593 custom_agent_form: None,
5594 external_agent_add_focus_handle: cx.focus_handle(),
5595 harness_maintenance: Default::default(),
5596 harness_maintenance_task: None,
5597 harness_maintenance_error: None,
5598 skill_creator_page: None,
5599 };
5600
5601 settings_window.build_filter_table();
5602 settings_window.build_navbar(cx);
5603 for expanded_page_index in expanded_pages {
5604 for entry in &mut settings_window.navbar_entries {
5605 if entry.page_index == expanded_page_index && entry.is_root {
5606 entry.expanded = true;
5607 }
5608 }
5609 }
5610 settings_window
5611 }
5612
5613 #[track_caller]
5614 fn check_navbar_toggle(
5615 before: &'static str,
5616 toggle_page: &'static str,
5617 after: &'static str,
5618 window: &mut Window,
5619 cx: &mut App,
5620 ) {
5621 let mut settings_window = parse(before, window, cx);
5622 let toggle_page_idx = settings_window
5623 .pages
5624 .iter()
5625 .position(|page| page.title == toggle_page)
5626 .expect("page not found");
5627 let toggle_idx = settings_window
5628 .navbar_entries
5629 .iter()
5630 .position(|entry| entry.page_index == toggle_page_idx)
5631 .expect("page not found");
5632 settings_window.toggle_navbar_entry(toggle_idx);
5633
5634 let expected_settings_window = parse(after, window, cx);
5635
5636 pretty_assertions::assert_eq!(
5637 settings_window
5638 .visible_navbar_entries()
5639 .map(|(_, entry)| entry)
5640 .collect::<Vec<_>>(),
5641 expected_settings_window
5642 .visible_navbar_entries()
5643 .map(|(_, entry)| entry)
5644 .collect::<Vec<_>>(),
5645 );
5646 pretty_assertions::assert_eq!(
5647 settings_window.navbar_entries[settings_window.navbar_entry()],
5648 expected_settings_window.navbar_entries[expected_settings_window.navbar_entry()],
5649 );
5650 }
5651
5652 macro_rules! check_navbar_toggle {
5653 ($name:ident, before: $before:expr, toggle_page: $toggle_page:expr, after: $after:expr) => {
5654 #[gpui::test]
5655 fn $name(cx: &mut gpui::TestAppContext) {
5656 let window = cx.add_empty_window();
5657 window.update(|window, cx| {
5658 register_settings(cx);
5659 check_navbar_toggle($before, $toggle_page, $after, window, cx);
5660 });
5661 }
5662 };
5663 }
5664
5665 check_navbar_toggle!(
5666 navbar_basic_open,
5667 before: r"
5668 v General
5669 - General
5670 - Privacy*
5671 v Project
5672 - Project Settings
5673 ",
5674 toggle_page: "General",
5675 after: r"
5676 > General*
5677 v Project
5678 - Project Settings
5679 "
5680 );
5681
5682 check_navbar_toggle!(
5683 navbar_basic_close,
5684 before: r"
5685 > General*
5686 - General
5687 - Privacy
5688 v Project
5689 - Project Settings
5690 ",
5691 toggle_page: "General",
5692 after: r"
5693 v General*
5694 - General
5695 - Privacy
5696 v Project
5697 - Project Settings
5698 "
5699 );
5700
5701 check_navbar_toggle!(
5702 navbar_basic_second_root_entry_close,
5703 before: r"
5704 > General
5705 - General
5706 - Privacy
5707 v Project
5708 - Project Settings*
5709 ",
5710 toggle_page: "Project",
5711 after: r"
5712 > General
5713 > Project*
5714 "
5715 );
5716
5717 check_navbar_toggle!(
5718 navbar_toggle_subroot,
5719 before: r"
5720 v General Page
5721 - General
5722 - Privacy
5723 v Project
5724 - Worktree Settings Content*
5725 v AI
5726 - General
5727 > Appearance & Behavior
5728 ",
5729 toggle_page: "Project",
5730 after: r"
5731 v General Page
5732 - General
5733 - Privacy
5734 > Project*
5735 v AI
5736 - General
5737 > Appearance & Behavior
5738 "
5739 );
5740
5741 check_navbar_toggle!(
5742 navbar_toggle_close_propagates_selected_index,
5743 before: r"
5744 v General Page
5745 - General
5746 - Privacy
5747 v Project
5748 - Worktree Settings Content
5749 v AI
5750 - General*
5751 > Appearance & Behavior
5752 ",
5753 toggle_page: "General Page",
5754 after: r"
5755 > General Page*
5756 v Project
5757 - Worktree Settings Content
5758 v AI
5759 - General
5760 > Appearance & Behavior
5761 "
5762 );
5763
5764 check_navbar_toggle!(
5765 navbar_toggle_expand_propagates_selected_index,
5766 before: r"
5767 > General Page
5768 - General
5769 - Privacy
5770 v Project
5771 - Worktree Settings Content
5772 v AI
5773 - General*
5774 > Appearance & Behavior
5775 ",
5776 toggle_page: "General Page",
5777 after: r"
5778 v General Page*
5779 - General
5780 - Privacy
5781 v Project
5782 - Worktree Settings Content
5783 v AI
5784 - General
5785 > Appearance & Behavior
5786 "
5787 );
5788
5789 #[gpui::test]
5790 fn navbar_double_click_toggle(cx: &mut gpui::TestAppContext) {
5791 let (settings_window, cx) = cx.add_window_view(|window, cx| {
5792 register_settings(cx);
5793 let mut settings_window = parse(
5794 r"
5795 > General*
5796 - General
5797 - Privacy
5798 v Project
5799 - Project Settings
5800 ",
5801 window,
5802 cx,
5803 );
5804 settings_window.build_content_handles(window, cx);
5805 settings_window
5806 });
5807
5808 settings_window.update_in(cx, |settings_window, window, cx| {
5809 let general_idx = settings_window
5810 .navbar_entries
5811 .iter()
5812 .position(|entry| entry.title == "General" && entry.is_root)
5813 .expect("General root entry should exist");
5814 let privacy_idx = settings_window
5815 .navbar_entries
5816 .iter()
5817 .position(|entry| entry.title == "Privacy" && !entry.is_root)
5818 .expect("Privacy nested entry should exist");
5819
5820 let click_event = |click_count| {
5821 gpui::ClickEvent::Mouse(gpui::MouseClickEvent {
5822 down: gpui::MouseDownEvent {
5823 button: gpui::MouseButton::Left,
5824 click_count,
5825 ..Default::default()
5826 },
5827 up: gpui::MouseUpEvent {
5828 button: gpui::MouseButton::Left,
5829 click_count,
5830 ..Default::default()
5831 },
5832 })
5833 };
5834
5835 assert!(
5836 !settings_window.toggle_navbar_entry_on_double_click(
5837 general_idx,
5838 &click_event(1),
5839 window,
5840 cx,
5841 ),
5842 "single-clicks should use the normal navigation path"
5843 );
5844 assert!(!settings_window.navbar_entries[general_idx].expanded);
5845
5846 assert!(settings_window.toggle_navbar_entry_on_double_click(
5847 general_idx,
5848 &click_event(2),
5849 window,
5850 cx,
5851 ));
5852 assert!(settings_window.navbar_entries[general_idx].expanded);
5853
5854 assert!(
5855 !settings_window.toggle_navbar_entry_on_double_click(
5856 general_idx,
5857 &click_event(3),
5858 window,
5859 cx,
5860 ),
5861 "triple-clicks should not toggle the entry again"
5862 );
5863 assert!(settings_window.navbar_entries[general_idx].expanded);
5864
5865 assert!(!settings_window.toggle_navbar_entry_on_double_click(
5866 privacy_idx,
5867 &click_event(2),
5868 window,
5869 cx,
5870 ));
5871 });
5872 }
5873
5874 #[gpui::test]
5875 async fn test_settings_window_shows_worktrees_from_multiple_workspaces(
5876 cx: &mut gpui::TestAppContext,
5877 ) {
5878 use project::Project;
5879 use serde_json::json;
5880
5881 cx.update(|cx| {
5882 register_settings(cx);
5883 });
5884
5885 let app_state = cx.update(|cx| {
5886 let app_state = AppState::test(cx);
5887 AppState::set_global(app_state.clone(), cx);
5888 app_state
5889 });
5890
5891 let fake_fs = app_state.fs.as_fake();
5892
5893 fake_fs
5894 .insert_tree(
5895 "/workspace1",
5896 json!({
5897 "worktree_a": {
5898 "file1.rs": "fn main() {}"
5899 },
5900 "worktree_b": {
5901 "file2.rs": "fn test() {}"
5902 }
5903 }),
5904 )
5905 .await;
5906
5907 fake_fs
5908 .insert_tree(
5909 "/workspace2",
5910 json!({
5911 "worktree_c": {
5912 "file3.rs": "fn foo() {}"
5913 }
5914 }),
5915 )
5916 .await;
5917
5918 let project1 = cx.update(|cx| {
5919 Project::local(
5920 app_state.client.clone(),
5921 app_state.node_runtime.clone(),
5922 app_state.user_store.clone(),
5923 app_state.languages.clone(),
5924 app_state.fs.clone(),
5925 None,
5926 project::LocalProjectFlags::default(),
5927 cx,
5928 )
5929 });
5930
5931 project1
5932 .update(cx, |project, cx| {
5933 project.find_or_create_worktree("/workspace1/worktree_a", true, cx)
5934 })
5935 .await
5936 .expect("Failed to create worktree_a");
5937 project1
5938 .update(cx, |project, cx| {
5939 project.find_or_create_worktree("/workspace1/worktree_b", true, cx)
5940 })
5941 .await
5942 .expect("Failed to create worktree_b");
5943
5944 let project2 = cx.update(|cx| {
5945 Project::local(
5946 app_state.client.clone(),
5947 app_state.node_runtime.clone(),
5948 app_state.user_store.clone(),
5949 app_state.languages.clone(),
5950 app_state.fs.clone(),
5951 None,
5952 project::LocalProjectFlags::default(),
5953 cx,
5954 )
5955 });
5956
5957 project2
5958 .update(cx, |project, cx| {
5959 project.find_or_create_worktree("/workspace2/worktree_c", true, cx)
5960 })
5961 .await
5962 .expect("Failed to create worktree_c");
5963
5964 let (_multi_workspace1, cx) = cx.add_window_view(|window, cx| {
5965 let workspace = cx.new(|cx| {
5966 Workspace::new(
5967 Default::default(),
5968 project1.clone(),
5969 app_state.clone(),
5970 window,
5971 cx,
5972 )
5973 });
5974 MultiWorkspace::new(workspace, window, cx)
5975 });
5976
5977 let (_multi_workspace2, cx) = cx.add_window_view(|window, cx| {
5978 let workspace = cx.new(|cx| {
5979 Workspace::new(
5980 Default::default(),
5981 project2.clone(),
5982 app_state.clone(),
5983 window,
5984 cx,
5985 )
5986 });
5987 MultiWorkspace::new(workspace, window, cx)
5988 });
5989
5990 let workspace2_handle = cx.window_handle().downcast::<MultiWorkspace>().unwrap();
5991
5992 cx.run_until_parked();
5993
5994 let (settings_window, cx) = cx
5995 .add_window_view(|window, cx| SettingsWindow::new(Some(workspace2_handle), window, cx));
5996
5997 cx.run_until_parked();
5998
5999 settings_window.read_with(cx, |settings_window, _| {
6000 let worktree_names: Vec<_> = settings_window
6001 .worktree_root_dirs
6002 .values()
6003 .cloned()
6004 .collect();
6005
6006 assert!(
6007 worktree_names.iter().any(|name| name == "worktree_a"),
6008 "Should contain worktree_a from workspace1, but found: {:?}",
6009 worktree_names
6010 );
6011 assert!(
6012 worktree_names.iter().any(|name| name == "worktree_b"),
6013 "Should contain worktree_b from workspace1, but found: {:?}",
6014 worktree_names
6015 );
6016 assert!(
6017 worktree_names.iter().any(|name| name == "worktree_c"),
6018 "Should contain worktree_c from workspace2, but found: {:?}",
6019 worktree_names
6020 );
6021
6022 assert_eq!(
6023 worktree_names.len(),
6024 3,
6025 "Should have exactly 3 worktrees from both workspaces, but found: {:?}",
6026 worktree_names
6027 );
6028
6029 let project_files: Vec<_> = settings_window
6030 .files
6031 .iter()
6032 .filter_map(|(f, _)| match f {
6033 SettingsUiFile::Project((worktree_id, _)) => Some(*worktree_id),
6034 _ => None,
6035 })
6036 .collect();
6037
6038 let unique_project_files: std::collections::HashSet<_> = project_files.iter().collect();
6039 assert_eq!(
6040 project_files.len(),
6041 unique_project_files.len(),
6042 "Should have no duplicate project files, but found duplicates. All files: {:?}",
6043 project_files
6044 );
6045 });
6046 }
6047
6048 #[gpui::test]
6049 async fn test_settings_window_updates_when_new_workspace_created(
6050 cx: &mut gpui::TestAppContext,
6051 ) {
6052 use project::Project;
6053 use serde_json::json;
6054
6055 cx.update(|cx| {
6056 register_settings(cx);
6057 });
6058
6059 let app_state = cx.update(|cx| {
6060 let app_state = AppState::test(cx);
6061 AppState::set_global(app_state.clone(), cx);
6062 app_state
6063 });
6064
6065 let fake_fs = app_state.fs.as_fake();
6066
6067 fake_fs
6068 .insert_tree(
6069 "/workspace1",
6070 json!({
6071 "worktree_a": {
6072 "file1.rs": "fn main() {}"
6073 }
6074 }),
6075 )
6076 .await;
6077
6078 fake_fs
6079 .insert_tree(
6080 "/workspace2",
6081 json!({
6082 "worktree_b": {
6083 "file2.rs": "fn test() {}"
6084 }
6085 }),
6086 )
6087 .await;
6088
6089 let project1 = cx.update(|cx| {
6090 Project::local(
6091 app_state.client.clone(),
6092 app_state.node_runtime.clone(),
6093 app_state.user_store.clone(),
6094 app_state.languages.clone(),
6095 app_state.fs.clone(),
6096 None,
6097 project::LocalProjectFlags::default(),
6098 cx,
6099 )
6100 });
6101
6102 project1
6103 .update(cx, |project, cx| {
6104 project.find_or_create_worktree("/workspace1/worktree_a", true, cx)
6105 })
6106 .await
6107 .expect("Failed to create worktree_a");
6108
6109 let (_multi_workspace1, cx) = cx.add_window_view(|window, cx| {
6110 let workspace = cx.new(|cx| {
6111 Workspace::new(
6112 Default::default(),
6113 project1.clone(),
6114 app_state.clone(),
6115 window,
6116 cx,
6117 )
6118 });
6119 MultiWorkspace::new(workspace, window, cx)
6120 });
6121
6122 let workspace1_handle = cx.window_handle().downcast::<MultiWorkspace>().unwrap();
6123
6124 cx.run_until_parked();
6125
6126 let (settings_window, cx) = cx
6127 .add_window_view(|window, cx| SettingsWindow::new(Some(workspace1_handle), window, cx));
6128
6129 cx.run_until_parked();
6130
6131 settings_window.read_with(cx, |settings_window, _| {
6132 assert_eq!(
6133 settings_window.worktree_root_dirs.len(),
6134 1,
6135 "Should have 1 worktree initially"
6136 );
6137 });
6138
6139 let project2 = cx.update(|_, cx| {
6140 Project::local(
6141 app_state.client.clone(),
6142 app_state.node_runtime.clone(),
6143 app_state.user_store.clone(),
6144 app_state.languages.clone(),
6145 app_state.fs.clone(),
6146 None,
6147 project::LocalProjectFlags::default(),
6148 cx,
6149 )
6150 });
6151
6152 project2
6153 .update(&mut cx.cx, |project, cx| {
6154 project.find_or_create_worktree("/workspace2/worktree_b", true, cx)
6155 })
6156 .await
6157 .expect("Failed to create worktree_b");
6158
6159 let (_multi_workspace2, cx) = cx.add_window_view(|window, cx| {
6160 let workspace = cx.new(|cx| {
6161 Workspace::new(
6162 Default::default(),
6163 project2.clone(),
6164 app_state.clone(),
6165 window,
6166 cx,
6167 )
6168 });
6169 MultiWorkspace::new(workspace, window, cx)
6170 });
6171
6172 cx.run_until_parked();
6173
6174 settings_window.read_with(cx, |settings_window, _| {
6175 let worktree_names: Vec<_> = settings_window
6176 .worktree_root_dirs
6177 .values()
6178 .cloned()
6179 .collect();
6180
6181 assert!(
6182 worktree_names.iter().any(|name| name == "worktree_a"),
6183 "Should contain worktree_a, but found: {:?}",
6184 worktree_names
6185 );
6186 assert!(
6187 worktree_names.iter().any(|name| name == "worktree_b"),
6188 "Should contain worktree_b from newly created workspace, but found: {:?}",
6189 worktree_names
6190 );
6191
6192 assert_eq!(
6193 worktree_names.len(),
6194 2,
6195 "Should have 2 worktrees after new workspace created, but found: {:?}",
6196 worktree_names
6197 );
6198
6199 let project_files: Vec<_> = settings_window
6200 .files
6201 .iter()
6202 .filter_map(|(f, _)| match f {
6203 SettingsUiFile::Project((worktree_id, _)) => Some(*worktree_id),
6204 _ => None,
6205 })
6206 .collect();
6207
6208 let unique_project_files: std::collections::HashSet<_> = project_files.iter().collect();
6209 assert_eq!(
6210 project_files.len(),
6211 unique_project_files.len(),
6212 "Should have no duplicate project files, but found duplicates. All files: {:?}",
6213 project_files
6214 );
6215 });
6216 }
6217
6218 #[gpui::test]
6219 async fn test_skills_page_scope_switch_updates_displayed_skills(cx: &mut gpui::TestAppContext) {
6220 use agent_skills::{
6221 ProjectSkillGroup, Skill, SkillScopeId, SkillSource, load_skills_from_directory,
6222 };
6223 use project::Project;
6224 use serde_json::json;
6225 use std::path::Path;
6226
6227 cx.update(|cx| {
6228 register_settings(cx);
6229 });
6230
6231 let app_state = cx.update(|cx| {
6232 let app_state = AppState::test(cx);
6233 AppState::set_global(app_state.clone(), cx);
6234 app_state
6235 });
6236
6237 let fake_fs = app_state.fs.as_fake();
6238
6239 fake_fs
6240 .insert_tree(
6241 "/global-skills",
6242 json!({
6243 "global-skill": {
6244 "SKILL.md": "---\nname: global-skill\ndescription: A user level skill\n---\n\nGlobal instructions."
6245 }
6246 }),
6247 )
6248 .await;
6249
6250 fake_fs
6251 .insert_tree(
6252 "/project",
6253 json!({
6254 ".agents": {
6255 "skills": {
6256 "project-skill": {
6257 "SKILL.md": "---\nname: project-skill\ndescription: A project level skill\n---\n\nProject instructions."
6258 }
6259 }
6260 },
6261 "main.rs": "fn main() {}"
6262 }),
6263 )
6264 .await;
6265
6266 let project = cx.update(|cx| {
6267 Project::local(
6268 app_state.client.clone(),
6269 app_state.node_runtime.clone(),
6270 app_state.user_store.clone(),
6271 app_state.languages.clone(),
6272 app_state.fs.clone(),
6273 None,
6274 project::LocalProjectFlags::default(),
6275 cx,
6276 )
6277 });
6278
6279 let (worktree, _) = project
6280 .update(cx, |project, cx| {
6281 project.find_or_create_worktree("/project", true, cx)
6282 })
6283 .await
6284 .expect("Failed to create worktree");
6285 let worktree_id = worktree.read_with(cx, |worktree, _| worktree.id());
6286
6287 // Load both skills from the fake filesystem the same way the agent
6288 // does, then publish them as the global skill index.
6289 let fs = app_state.fs.clone();
6290 let global_skills: Vec<Skill> =
6291 load_skills_from_directory(&fs, Path::new("/global-skills"), SkillSource::Global)
6292 .await
6293 .into_iter()
6294 .map(|result| result.expect("global skill should load"))
6295 .collect();
6296 let project_skills: Vec<Skill> = load_skills_from_directory(
6297 &fs,
6298 Path::new("/project/.agents/skills"),
6299 SkillSource::ProjectLocal {
6300 worktree_id: SkillScopeId(worktree_id.to_usize()),
6301 worktree_root_name: "project".into(),
6302 },
6303 )
6304 .await
6305 .into_iter()
6306 .map(|result| result.expect("project skill should load"))
6307 .collect();
6308 assert_eq!(global_skills.len(), 1);
6309 assert_eq!(project_skills.len(), 1);
6310
6311 cx.update(|cx| {
6312 cx.set_global(SkillIndex {
6313 global_skills,
6314 project_skills: vec![ProjectSkillGroup {
6315 worktree_id: SkillScopeId(worktree_id.to_usize()),
6316 worktree_root_name: "project".into(),
6317 skills: project_skills,
6318 }],
6319 });
6320 });
6321
6322 let (_multi_workspace, cx) = cx.add_window_view(|window, cx| {
6323 let workspace = cx.new(|cx| {
6324 Workspace::new(
6325 Default::default(),
6326 project.clone(),
6327 app_state.clone(),
6328 window,
6329 cx,
6330 )
6331 });
6332 MultiWorkspace::new(workspace, window, cx)
6333 });
6334 let workspace_handle = cx.window_handle().downcast::<MultiWorkspace>().unwrap();
6335
6336 cx.run_until_parked();
6337
6338 let (settings_window, cx) = cx
6339 .add_window_view(|window, cx| SettingsWindow::new(Some(workspace_handle), window, cx));
6340
6341 cx.run_until_parked();
6342
6343 settings_window.update_in(cx, |settings_window, window, cx| {
6344 fn displayed_skill_names(settings_window: &SettingsWindow, cx: &App) -> Vec<String> {
6345 crate::pages::displayed_skills(settings_window, cx)
6346 .iter()
6347 .map(|skill| skill.name.to_string())
6348 .collect()
6349 }
6350
6351 assert_eq!(settings_window.current_file, SettingsUiFile::User);
6352 assert!(
6353 settings_window.navigate_to_sub_page(AGENT_SKILLS_SETTINGS_PATH, window, cx),
6354 "Skills sub-page should exist"
6355 );
6356 assert_eq!(displayed_skill_names(settings_window, cx), ["global-skill"]);
6357
6358 let project_file_index = settings_window
6359 .files
6360 .iter()
6361 .position(|(file, _)| file.worktree_id() == Some(worktree_id))
6362 .expect("project settings file should be listed");
6363 settings_window.change_file_in_sub_page(project_file_index, window, cx);
6364
6365 assert_eq!(
6366 settings_window.current_file.worktree_id(),
6367 Some(worktree_id)
6368 );
6369 assert_eq!(
6370 settings_window.sub_page_stack.len(),
6371 1,
6372 "Skills sub-page should stay open when switching scope"
6373 );
6374 assert_eq!(settings_window.sub_page_stack[0].link.title, "Skills");
6375 assert_eq!(
6376 displayed_skill_names(settings_window, cx),
6377 ["project-skill"]
6378 );
6379
6380 let user_file_index = settings_window
6381 .files
6382 .iter()
6383 .position(|(file, _)| file == &SettingsUiFile::User)
6384 .expect("user settings file should be listed");
6385 settings_window.change_file_in_sub_page(user_file_index, window, cx);
6386
6387 assert_eq!(settings_window.current_file, SettingsUiFile::User);
6388 assert_eq!(settings_window.sub_page_stack.len(), 1);
6389 assert_eq!(displayed_skill_names(settings_window, cx), ["global-skill"]);
6390 });
6391 }
6392
6393 #[gpui::test]
6394 async fn test_open_skill_creator_navigates_to_sub_page(cx: &mut gpui::TestAppContext) {
6395 use project::Project;
6396
6397 cx.update(|cx| {
6398 register_settings(cx);
6399 });
6400
6401 let app_state = cx.update(|cx| {
6402 let app_state = AppState::test(cx);
6403 AppState::set_global(app_state.clone(), cx);
6404 app_state
6405 });
6406
6407 app_state
6408 .fs
6409 .as_fake()
6410 .insert_tree("/project", serde_json::json!({ "main.rs": "fn main() {}" }))
6411 .await;
6412
6413 let project = cx.update(|cx| {
6414 Project::local(
6415 app_state.client.clone(),
6416 app_state.node_runtime.clone(),
6417 app_state.user_store.clone(),
6418 app_state.languages.clone(),
6419 app_state.fs.clone(),
6420 None,
6421 project::LocalProjectFlags::default(),
6422 cx,
6423 )
6424 });
6425 project
6426 .update(cx, |project, cx| {
6427 project.find_or_create_worktree("/project", true, cx)
6428 })
6429 .await
6430 .expect("Failed to create worktree");
6431
6432 let (_multi_workspace, cx) = cx.add_window_view(|window, cx| {
6433 let workspace = cx.new(|cx| {
6434 Workspace::new(
6435 Default::default(),
6436 project.clone(),
6437 app_state.clone(),
6438 window,
6439 cx,
6440 )
6441 });
6442 MultiWorkspace::new(workspace, window, cx)
6443 });
6444 let workspace_handle = cx.window_handle().downcast::<MultiWorkspace>().unwrap();
6445
6446 cx.run_until_parked();
6447
6448 let (settings_window, cx) = cx
6449 .add_window_view(|window, cx| SettingsWindow::new(Some(workspace_handle), window, cx));
6450
6451 cx.run_until_parked();
6452
6453 settings_window.update_in(cx, |settings_window, window, cx| {
6454 settings_window.navigate_to_skill_creator(
6455 pages::SkillCreatorOpenMode::Form,
6456 window,
6457 cx,
6458 );
6459 });
6460
6461 cx.run_until_parked();
6462
6463 settings_window.read_with(cx, |settings_window, _| {
6464 let titles: Vec<_> = settings_window
6465 .sub_page_stack
6466 .iter()
6467 .map(|sub_page| sub_page.link.title.to_string())
6468 .collect();
6469 assert_eq!(
6470 titles,
6471 ["Skills", "Create Skill"],
6472 "skill creator should be pushed on top of the skills page"
6473 );
6474 assert!(
6475 settings_window.skill_creator_page().is_some(),
6476 "skill creator page state should exist"
6477 );
6478 });
6479 }
6480
6481 #[gpui::test]
6482 async fn test_open_skill_creator_action_opens_settings_window_at_sub_page(
6483 cx: &mut gpui::TestAppContext,
6484 ) {
6485 use project::Project;
6486
6487 cx.update(|cx| {
6488 register_settings(cx);
6489 release_channel::init("0.0.0".parse().unwrap(), cx);
6490 crate::init(cx);
6491 });
6492
6493 let app_state = cx.update(|cx| {
6494 let app_state = AppState::test(cx);
6495 AppState::set_global(app_state.clone(), cx);
6496 app_state
6497 });
6498
6499 app_state
6500 .fs
6501 .as_fake()
6502 .insert_tree("/project", serde_json::json!({ "main.rs": "fn main() {}" }))
6503 .await;
6504
6505 let project = cx.update(|cx| {
6506 Project::local(
6507 app_state.client.clone(),
6508 app_state.node_runtime.clone(),
6509 app_state.user_store.clone(),
6510 app_state.languages.clone(),
6511 app_state.fs.clone(),
6512 None,
6513 project::LocalProjectFlags::default(),
6514 cx,
6515 )
6516 });
6517 project
6518 .update(cx, |project, cx| {
6519 project.find_or_create_worktree("/project", true, cx)
6520 })
6521 .await
6522 .expect("Failed to create worktree");
6523
6524 let (multi_workspace, cx) = cx.add_window_view(|window, cx| {
6525 let workspace = cx.new(|cx| {
6526 Workspace::new(
6527 Default::default(),
6528 project.clone(),
6529 app_state.clone(),
6530 window,
6531 cx,
6532 )
6533 });
6534 MultiWorkspace::new(workspace, window, cx)
6535 });
6536
6537 cx.run_until_parked();
6538
6539 // Dispatch the action the way the command palette does: on the
6540 // workspace window.
6541 multi_workspace.update_in(cx, |_multi_workspace, window, cx| {
6542 window.dispatch_action(Box::new(zed_actions::assistant::OpenSkillCreator), cx);
6543 });
6544
6545 cx.run_until_parked();
6546
6547 let settings_window = cx
6548 .update(|_, cx| {
6549 cx.windows()
6550 .into_iter()
6551 .find_map(|window| window.downcast::<SettingsWindow>())
6552 })
6553 .expect("dispatching agent::OpenSkillCreator should open the settings window");
6554
6555 settings_window
6556 .read_with(cx, |settings_window, _| {
6557 let titles: Vec<_> = settings_window
6558 .sub_page_stack
6559 .iter()
6560 .map(|sub_page| sub_page.link.title.to_string())
6561 .collect();
6562 assert_eq!(
6563 titles,
6564 ["Skills", "Create Skill"],
6565 "skill creator should be pushed on top of the skills page"
6566 );
6567 })
6568 .unwrap();
6569 }
6570}
6571
6572#[cfg(test)]
6573mod project_settings_update_tests {
6574 use super::*;
6575 use fs::{FakeFs, Fs as _};
6576 use gpui::TestAppContext;
6577 use project::Project;
6578 use serde_json::json;
6579 use std::sync::atomic::{AtomicUsize, Ordering};
6580
6581 struct TestSetup {
6582 fs: Arc<FakeFs>,
6583 project: Entity<Project>,
6584 worktree_id: WorktreeId,
6585 worktree: WeakEntity<Worktree>,
6586 rel_path: Arc<RelPath>,
6587 project_path: ProjectPath,
6588 }
6589
6590 async fn init_test(cx: &mut TestAppContext, initial_settings: Option<&str>) -> TestSetup {
6591 cx.update(|cx| {
6592 let store = settings::SettingsStore::test(cx);
6593 cx.set_global(store);
6594 theme_settings::init(theme::LoadThemes::JustBase, cx);
6595 editor::init(cx);
6596 menu::init();
6597 let queue = ProjectSettingsUpdateQueue::new(cx);
6598 cx.set_global(queue);
6599 });
6600
6601 let fs = FakeFs::new(cx.executor());
6602 let tree = if let Some(settings_content) = initial_settings {
6603 json!({
6604 ".zed": {
6605 "settings.json": settings_content
6606 },
6607 "src": { "main.rs": "" }
6608 })
6609 } else {
6610 json!({ "src": { "main.rs": "" } })
6611 };
6612 fs.insert_tree("/project", tree).await;
6613
6614 let project = Project::test(fs.clone(), ["/project".as_ref()], cx).await;
6615
6616 let (worktree_id, worktree) = project.read_with(cx, |project, cx| {
6617 let worktree = project.worktrees(cx).next().unwrap();
6618 (worktree.read(cx).id(), worktree.downgrade())
6619 });
6620
6621 let rel_path: Arc<RelPath> = RelPath::from_unix_str(".zed/settings.json")
6622 .expect("valid path")
6623 .into_arc();
6624 let project_path = ProjectPath {
6625 worktree_id,
6626 path: rel_path.clone(),
6627 };
6628
6629 TestSetup {
6630 fs,
6631 project,
6632 worktree_id,
6633 worktree,
6634 rel_path,
6635 project_path,
6636 }
6637 }
6638
6639 #[gpui::test]
6640 async fn test_creates_settings_file_if_missing(cx: &mut TestAppContext) {
6641 let setup = init_test(cx, None).await;
6642
6643 let entry = ProjectSettingsUpdateEntry {
6644 worktree_id: setup.worktree_id,
6645 rel_path: setup.rel_path.clone(),
6646 settings_window: WeakEntity::new_invalid(),
6647 project: setup.project.downgrade(),
6648 worktree: setup.worktree,
6649 update: Box::new(|content, _cx| {
6650 content.project.all_languages.defaults.tab_size = Some(NonZeroU32::new(4).unwrap());
6651 }),
6652 };
6653
6654 cx.update(|cx| ProjectSettingsUpdateQueue::enqueue(cx, entry));
6655 cx.executor().run_until_parked();
6656
6657 let buffer_store = setup
6658 .project
6659 .read_with(cx, |project, _| project.buffer_store().clone());
6660 let buffer = buffer_store
6661 .update(cx, |store, cx| store.open_buffer(setup.project_path, cx))
6662 .await
6663 .expect("buffer should exist");
6664
6665 let text = buffer.read_with(cx, |buffer, _| buffer.text());
6666 assert!(
6667 text.contains("\"tab_size\": 4"),
6668 "Expected tab_size setting in: {}",
6669 text
6670 );
6671 }
6672
6673 #[gpui::test]
6674 async fn test_updates_existing_settings_file(cx: &mut TestAppContext) {
6675 let setup = init_test(cx, Some(r#"{ "tab_size": 2 }"#)).await;
6676
6677 let entry = ProjectSettingsUpdateEntry {
6678 worktree_id: setup.worktree_id,
6679 rel_path: setup.rel_path.clone(),
6680 settings_window: WeakEntity::new_invalid(),
6681 project: setup.project.downgrade(),
6682 worktree: setup.worktree,
6683 update: Box::new(|content, _cx| {
6684 content.project.all_languages.defaults.tab_size = Some(NonZeroU32::new(8).unwrap());
6685 }),
6686 };
6687
6688 cx.update(|cx| ProjectSettingsUpdateQueue::enqueue(cx, entry));
6689 cx.executor().run_until_parked();
6690
6691 let buffer_store = setup
6692 .project
6693 .read_with(cx, |project, _| project.buffer_store().clone());
6694 let buffer = buffer_store
6695 .update(cx, |store, cx| store.open_buffer(setup.project_path, cx))
6696 .await
6697 .expect("buffer should exist");
6698
6699 let text = buffer.read_with(cx, |buffer, _| buffer.text());
6700 assert!(
6701 text.contains("\"tab_size\": 8"),
6702 "Expected updated tab_size in: {}",
6703 text
6704 );
6705 }
6706
6707 #[gpui::test]
6708 async fn test_updates_are_serialized(cx: &mut TestAppContext) {
6709 let setup = init_test(cx, Some("{}")).await;
6710
6711 let update_order = Arc::new(std::sync::Mutex::new(Vec::new()));
6712
6713 for i in 1..=3 {
6714 let update_order = update_order.clone();
6715 let entry = ProjectSettingsUpdateEntry {
6716 worktree_id: setup.worktree_id,
6717 rel_path: setup.rel_path.clone(),
6718 settings_window: WeakEntity::new_invalid(),
6719 project: setup.project.downgrade(),
6720 worktree: setup.worktree.clone(),
6721 update: Box::new(move |content, _cx| {
6722 update_order.lock().unwrap().push(i);
6723 content.project.all_languages.defaults.tab_size =
6724 Some(NonZeroU32::new(i).unwrap());
6725 }),
6726 };
6727 cx.update(|cx| ProjectSettingsUpdateQueue::enqueue(cx, entry));
6728 }
6729
6730 cx.executor().run_until_parked();
6731
6732 let order = update_order.lock().unwrap().clone();
6733 assert_eq!(order, vec![1, 2, 3], "Updates should be processed in order");
6734
6735 let buffer_store = setup
6736 .project
6737 .read_with(cx, |project, _| project.buffer_store().clone());
6738 let buffer = buffer_store
6739 .update(cx, |store, cx| store.open_buffer(setup.project_path, cx))
6740 .await
6741 .expect("buffer should exist");
6742
6743 let text = buffer.read_with(cx, |buffer, _| buffer.text());
6744 assert!(
6745 text.contains("\"tab_size\": 3"),
6746 "Final tab_size should be 3: {}",
6747 text
6748 );
6749 }
6750
6751 #[gpui::test]
6752 async fn test_queue_continues_after_failure(cx: &mut TestAppContext) {
6753 let setup = init_test(cx, Some("{}")).await;
6754
6755 let successful_updates = Arc::new(AtomicUsize::new(0));
6756
6757 {
6758 let successful_updates = successful_updates.clone();
6759 let entry = ProjectSettingsUpdateEntry {
6760 worktree_id: setup.worktree_id,
6761 rel_path: setup.rel_path.clone(),
6762 settings_window: WeakEntity::new_invalid(),
6763 project: setup.project.downgrade(),
6764 worktree: setup.worktree.clone(),
6765 update: Box::new(move |content, _cx| {
6766 successful_updates.fetch_add(1, Ordering::SeqCst);
6767 content.project.all_languages.defaults.tab_size =
6768 Some(NonZeroU32::new(2).unwrap());
6769 }),
6770 };
6771 cx.update(|cx| ProjectSettingsUpdateQueue::enqueue(cx, entry));
6772 }
6773
6774 {
6775 let entry = ProjectSettingsUpdateEntry {
6776 worktree_id: setup.worktree_id,
6777 rel_path: setup.rel_path.clone(),
6778 settings_window: WeakEntity::new_invalid(),
6779 project: WeakEntity::new_invalid(),
6780 worktree: setup.worktree.clone(),
6781 update: Box::new(|content, _cx| {
6782 content.project.all_languages.defaults.tab_size =
6783 Some(NonZeroU32::new(99).unwrap());
6784 }),
6785 };
6786 cx.update(|cx| ProjectSettingsUpdateQueue::enqueue(cx, entry));
6787 }
6788
6789 {
6790 let successful_updates = successful_updates.clone();
6791 let entry = ProjectSettingsUpdateEntry {
6792 worktree_id: setup.worktree_id,
6793 rel_path: setup.rel_path.clone(),
6794 settings_window: WeakEntity::new_invalid(),
6795 project: setup.project.downgrade(),
6796 worktree: setup.worktree.clone(),
6797 update: Box::new(move |content, _cx| {
6798 successful_updates.fetch_add(1, Ordering::SeqCst);
6799 content.project.all_languages.defaults.tab_size =
6800 Some(NonZeroU32::new(4).unwrap());
6801 }),
6802 };
6803 cx.update(|cx| ProjectSettingsUpdateQueue::enqueue(cx, entry));
6804 }
6805
6806 cx.executor().run_until_parked();
6807
6808 assert_eq!(
6809 successful_updates.load(Ordering::SeqCst),
6810 2,
6811 "Two updates should have succeeded despite middle failure"
6812 );
6813
6814 let buffer_store = setup
6815 .project
6816 .read_with(cx, |project, _| project.buffer_store().clone());
6817 let buffer = buffer_store
6818 .update(cx, |store, cx| store.open_buffer(setup.project_path, cx))
6819 .await
6820 .expect("buffer should exist");
6821
6822 let text = buffer.read_with(cx, |buffer, _| buffer.text());
6823 assert!(
6824 text.contains("\"tab_size\": 4"),
6825 "Final tab_size should be 4 (third update): {}",
6826 text
6827 );
6828 }
6829
6830 #[gpui::test]
6831 async fn test_handles_dropped_worktree(cx: &mut TestAppContext) {
6832 let setup = init_test(cx, Some("{}")).await;
6833
6834 let entry = ProjectSettingsUpdateEntry {
6835 worktree_id: setup.worktree_id,
6836 rel_path: setup.rel_path.clone(),
6837 settings_window: WeakEntity::new_invalid(),
6838 project: setup.project.downgrade(),
6839 worktree: WeakEntity::new_invalid(),
6840 update: Box::new(|content, _cx| {
6841 content.project.all_languages.defaults.tab_size =
6842 Some(NonZeroU32::new(99).unwrap());
6843 }),
6844 };
6845
6846 cx.update(|cx| ProjectSettingsUpdateQueue::enqueue(cx, entry));
6847 cx.executor().run_until_parked();
6848
6849 let file_content = setup
6850 .fs
6851 .load("/project/.zed/settings.json".as_ref())
6852 .await
6853 .unwrap();
6854 assert_eq!(
6855 file_content, "{}",
6856 "File should be unchanged when worktree is dropped"
6857 );
6858 }
6859
6860 #[gpui::test]
6861 async fn test_reloads_conflicted_buffer(cx: &mut TestAppContext) {
6862 let setup = init_test(cx, Some(r#"{ "tab_size": 2 }"#)).await;
6863
6864 let buffer_store = setup
6865 .project
6866 .read_with(cx, |project, _| project.buffer_store().clone());
6867 let buffer = buffer_store
6868 .update(cx, |store, cx| {
6869 store.open_buffer(setup.project_path.clone(), cx)
6870 })
6871 .await
6872 .expect("buffer should exist");
6873
6874 buffer.update(cx, |buffer, cx| {
6875 buffer.edit([(0..0, "// comment\n")], None, cx);
6876 });
6877
6878 let has_unsaved_edits = buffer.read_with(cx, |buffer, _| buffer.has_unsaved_edits());
6879 assert!(has_unsaved_edits, "Buffer should have unsaved edits");
6880
6881 setup
6882 .fs
6883 .save(
6884 "/project/.zed/settings.json".as_ref(),
6885 &r#"{ "tab_size": 99 }"#.into(),
6886 Default::default(),
6887 )
6888 .await
6889 .expect("save should succeed");
6890
6891 cx.executor().run_until_parked();
6892
6893 let has_conflict = buffer.read_with(cx, |buffer, _| buffer.has_conflict());
6894 assert!(
6895 has_conflict,
6896 "Buffer should have conflict after external modification"
6897 );
6898
6899 let (settings_window, _) = cx.add_window_view(|window, cx| {
6900 let mut sw = SettingsWindow::test(window, cx);
6901 sw.project_setting_file_buffers
6902 .insert(setup.project_path.clone(), buffer.clone());
6903 sw
6904 });
6905
6906 let entry = ProjectSettingsUpdateEntry {
6907 worktree_id: setup.worktree_id,
6908 rel_path: setup.rel_path.clone(),
6909 settings_window: settings_window.downgrade(),
6910 project: setup.project.downgrade(),
6911 worktree: setup.worktree.clone(),
6912 update: Box::new(|content, _cx| {
6913 content.project.all_languages.defaults.tab_size = Some(NonZeroU32::new(4).unwrap());
6914 }),
6915 };
6916
6917 cx.update(|cx| ProjectSettingsUpdateQueue::enqueue(cx, entry));
6918 cx.executor().run_until_parked();
6919
6920 let text = buffer.read_with(cx, |buffer, _| buffer.text());
6921 assert!(
6922 text.contains("\"tab_size\": 4"),
6923 "Buffer should have the new tab_size after reload and update: {}",
6924 text
6925 );
6926 assert!(
6927 !text.contains("// comment"),
6928 "Buffer should not contain the unsaved edit after reload: {}",
6929 text
6930 );
6931 assert!(
6932 !text.contains("99"),
6933 "Buffer should not contain the external modification value: {}",
6934 text
6935 );
6936 }
6937}
6938