1//! # settings_ui
2use std::sync::Arc;
3
4use editor::Editor;
5use feature_flags::{FeatureFlag, FeatureFlagAppExt as _};
6use gpui::{
7 App, AppContext as _, Context, Div, Entity, IntoElement, ReadGlobal as _, Render, Window,
8 WindowHandle, WindowOptions, actions, div, px, size,
9};
10use project::WorktreeId;
11use settings::{SettingsContent, SettingsStore};
12use ui::{
13 ActiveTheme as _, AnyElement, BorrowAppContext as _, Button, Clickable as _, Color, Divider,
14 DropdownMenu, FluentBuilder as _, Icon, IconName, InteractiveElement as _, Label,
15 LabelCommon as _, LabelSize, ParentElement, SharedString, StatefulInteractiveElement as _,
16 Styled, Switch, h_flex, v_flex,
17};
18use util::{paths::PathStyle, rel_path::RelPath};
19
20fn user_settings_data() -> Vec<SettingsPage> {
21 vec![
22 SettingsPage {
23 title: "General Page",
24 items: vec![
25 SettingsPageItem::SectionHeader("General"),
26 SettingsPageItem::SettingItem(SettingItem {
27 title: "Confirm Quit",
28 description: "Whether to confirm before quitting Zed",
29 render: |file, _, cx| {
30 render_toggle_button("confirm_quit", file, cx, |settings_content| {
31 &mut settings_content.workspace.confirm_quit
32 })
33 },
34 }),
35 SettingsPageItem::SettingItem(SettingItem {
36 title: "Auto Update",
37 description: "Automatically update Zed (may be ignored on Linux if installed through a package manager)",
38 render: |file, _, cx| {
39 render_toggle_button("Auto Update", file, cx, |settings_content| {
40 &mut settings_content.auto_update
41 })
42 },
43 }),
44 ],
45 },
46 SettingsPage {
47 title: "Project",
48 items: vec![
49 SettingsPageItem::SectionHeader("Worktree Settings Content"),
50 SettingsPageItem::SettingItem(SettingItem {
51 title: "Project Name",
52 description: "The displayed name of this project. If not set, the root directory name",
53 render: |file, window, cx| {
54 render_text_field("project_name", file, window, cx, |settings_content| {
55 &mut settings_content.project.worktree.project_name
56 })
57 },
58 }),
59 ],
60 },
61 SettingsPage {
62 title: "AI",
63 items: vec![
64 SettingsPageItem::SectionHeader("General"),
65 SettingsPageItem::SettingItem(SettingItem {
66 title: "Disable AI",
67 description: "Whether to disable all AI features in Zed",
68 render: |file, _, cx| {
69 render_toggle_button("disable_AI", file, cx, |settings_content| {
70 &mut settings_content.disable_ai
71 })
72 },
73 }),
74 ],
75 },
76 SettingsPage {
77 title: "Appearance & Behavior",
78 items: vec![
79 SettingsPageItem::SectionHeader("Cursor"),
80 SettingsPageItem::SettingItem(SettingItem {
81 title: "Cursor Shape",
82 description: "Cursor shape for the editor",
83 render: |file, window, cx| {
84 render_dropdown::<settings::CursorShape>(
85 "cursor_shape",
86 file,
87 window,
88 cx,
89 |settings_content| &mut settings_content.editor.cursor_shape,
90 )
91 },
92 }),
93 ],
94 },
95 ]
96}
97
98fn project_settings_data() -> Vec<SettingsPage> {
99 vec![SettingsPage {
100 title: "Project",
101 items: vec![
102 SettingsPageItem::SectionHeader("Worktree Settings Content"),
103 SettingsPageItem::SettingItem(SettingItem {
104 title: "Project Name",
105 description: " The displayed name of this project. If not set, the root directory name",
106 render: |file, window, cx| {
107 render_text_field("project_name", file, window, cx, |settings_content| {
108 &mut settings_content.project.worktree.project_name
109 })
110 },
111 }),
112 ],
113 }]
114}
115
116pub struct SettingsUiFeatureFlag;
117
118impl FeatureFlag for SettingsUiFeatureFlag {
119 const NAME: &'static str = "settings-ui";
120}
121
122actions!(
123 zed,
124 [
125 /// Opens Settings Editor.
126 OpenSettingsEditor
127 ]
128);
129
130pub fn init(cx: &mut App) {
131 cx.observe_new(|workspace: &mut workspace::Workspace, _, _| {
132 workspace.register_action_renderer(|div, _, _, cx| {
133 let settings_ui_actions = [std::any::TypeId::of::<OpenSettingsEditor>()];
134 let has_flag = cx.has_flag::<SettingsUiFeatureFlag>();
135 command_palette_hooks::CommandPaletteFilter::update_global(cx, |filter, _| {
136 if has_flag {
137 filter.show_action_types(&settings_ui_actions);
138 } else {
139 filter.hide_action_types(&settings_ui_actions);
140 }
141 });
142 if has_flag {
143 div.on_action(cx.listener(|_, _: &OpenSettingsEditor, _, cx| {
144 open_settings_editor(cx).ok();
145 }))
146 } else {
147 div
148 }
149 });
150 })
151 .detach();
152}
153
154pub fn open_settings_editor(cx: &mut App) -> anyhow::Result<WindowHandle<SettingsWindow>> {
155 cx.open_window(
156 WindowOptions {
157 titlebar: None,
158 focus: true,
159 show: true,
160 kind: gpui::WindowKind::Normal,
161 window_min_size: Some(size(px(300.), px(500.))), // todo(settings_ui): Does this min_size make sense?
162 ..Default::default()
163 },
164 |window, cx| cx.new(|cx| SettingsWindow::new(window, cx)),
165 )
166}
167
168pub struct SettingsWindow {
169 files: Vec<SettingsFile>,
170 current_file: SettingsFile,
171 pages: Vec<SettingsPage>,
172 search: Entity<Editor>,
173 current_page: usize, // Index into pages - should probably be (usize, Option<usize>) for section + page
174}
175
176#[derive(Clone)]
177struct SettingsPage {
178 title: &'static str,
179 items: Vec<SettingsPageItem>,
180}
181
182#[derive(Clone)]
183enum SettingsPageItem {
184 SectionHeader(&'static str),
185 SettingItem(SettingItem),
186}
187
188impl SettingsPageItem {
189 fn render(&self, file: SettingsFile, window: &mut Window, cx: &mut App) -> AnyElement {
190 match self {
191 SettingsPageItem::SectionHeader(header) => div()
192 .w_full()
193 .child(Label::new(SharedString::new_static(header)).size(LabelSize::Large))
194 .child(Divider::horizontal().color(ui::DividerColor::BorderVariant))
195 .into_any_element(),
196 SettingsPageItem::SettingItem(setting_item) => div()
197 .child(
198 Label::new(SharedString::new_static(setting_item.title))
199 .size(LabelSize::Default),
200 )
201 .child(
202 h_flex()
203 .justify_between()
204 .child(
205 div()
206 .child(
207 Label::new(SharedString::new_static(setting_item.description))
208 .size(LabelSize::Small)
209 .color(Color::Muted),
210 )
211 .max_w_1_2(),
212 )
213 .child((setting_item.render)(file, window, cx)),
214 )
215 .into_any_element(),
216 }
217 }
218}
219
220impl SettingsPageItem {
221 fn _header(&self) -> Option<&'static str> {
222 match self {
223 SettingsPageItem::SectionHeader(header) => Some(header),
224 _ => None,
225 }
226 }
227}
228
229#[derive(Clone)]
230struct SettingItem {
231 title: &'static str,
232 description: &'static str,
233 render: fn(file: SettingsFile, &mut Window, &mut App) -> AnyElement,
234}
235
236#[allow(unused)]
237#[derive(Clone, PartialEq)]
238enum SettingsFile {
239 User, // Uses all settings.
240 Local((WorktreeId, Arc<RelPath>)), // Has a special name, and special set of settings
241 Server(&'static str), // Uses a special name, and the user settings
242}
243
244impl SettingsFile {
245 fn pages(&self) -> Vec<SettingsPage> {
246 match self {
247 SettingsFile::User => user_settings_data(),
248 SettingsFile::Local(_) => project_settings_data(),
249 SettingsFile::Server(_) => user_settings_data(),
250 }
251 }
252
253 fn name(&self) -> SharedString {
254 match self {
255 SettingsFile::User => SharedString::new_static("User"),
256 // TODO is PathStyle::local() ever not appropriate?
257 SettingsFile::Local((_, path)) => {
258 format!("Local ({})", path.display(PathStyle::local())).into()
259 }
260 SettingsFile::Server(file) => format!("Server ({})", file).into(),
261 }
262 }
263}
264
265impl SettingsWindow {
266 pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
267 let current_file = SettingsFile::User;
268 let search = cx.new(|cx| {
269 let mut editor = Editor::single_line(window, cx);
270 editor.set_placeholder_text("Search Settings", window, cx);
271 editor
272 });
273 let mut this = Self {
274 files: vec![],
275 current_file: current_file,
276 pages: vec![],
277 current_page: 0,
278 search,
279 };
280 cx.observe_global_in::<SettingsStore>(window, move |this, _, cx| {
281 this.fetch_files(cx);
282 cx.notify();
283 })
284 .detach();
285 this.fetch_files(cx);
286
287 this.build_ui();
288 this
289 }
290
291 fn build_ui(&mut self) {
292 self.pages = self.current_file.pages();
293 }
294
295 fn fetch_files(&mut self, cx: &mut App) {
296 let settings_store = cx.global::<SettingsStore>();
297 let mut ui_files = vec![];
298 let all_files = settings_store.get_all_files();
299 for file in all_files {
300 let settings_ui_file = match file {
301 settings::SettingsFile::User => SettingsFile::User,
302 settings::SettingsFile::Global => continue,
303 settings::SettingsFile::Extension => continue,
304 settings::SettingsFile::Server => SettingsFile::Server("todo: server name"),
305 settings::SettingsFile::Default => continue,
306 settings::SettingsFile::Local(location) => SettingsFile::Local(location),
307 };
308 ui_files.push(settings_ui_file);
309 }
310 ui_files.reverse();
311 if !ui_files.contains(&self.current_file) {
312 self.change_file(0);
313 }
314 self.files = ui_files;
315 }
316
317 fn change_file(&mut self, ix: usize) {
318 if ix >= self.files.len() {
319 self.current_file = SettingsFile::User;
320 return;
321 }
322 if self.files[ix] == self.current_file {
323 return;
324 }
325 self.current_file = self.files[ix].clone();
326 self.build_ui();
327 }
328
329 fn render_files(&self, _window: &mut Window, cx: &mut Context<SettingsWindow>) -> Div {
330 div()
331 .flex()
332 .flex_row()
333 .gap_1()
334 .children(self.files.iter().enumerate().map(|(ix, file)| {
335 Button::new(ix, file.name())
336 .on_click(cx.listener(move |this, _, _window, _cx| this.change_file(ix)))
337 }))
338 }
339
340 fn render_search(&self, _window: &mut Window, _cx: &mut App) -> Div {
341 div()
342 .child(Icon::new(IconName::MagnifyingGlass))
343 .child(self.search.clone())
344 }
345
346 fn render_nav(&self, window: &mut Window, cx: &mut Context<SettingsWindow>) -> Div {
347 let mut nav = v_flex()
348 .p_4()
349 .gap_2()
350 .bg(cx.theme().colors().panel_background)
351 .child(div().h_10()) // Files spacer;
352 .child(self.render_search(window, cx));
353
354 for (ix, page) in self.pages.iter().enumerate() {
355 nav = nav.child(
356 div()
357 .id(page.title)
358 .child(
359 Label::new(page.title)
360 .size(LabelSize::Large)
361 .when(self.is_page_selected(ix), |this| {
362 this.color(Color::Selected)
363 }),
364 )
365 .on_click(cx.listener(move |this, _, _, cx| {
366 this.current_page = ix;
367 cx.notify();
368 })),
369 );
370 }
371 nav
372 }
373
374 fn render_page(
375 &self,
376 page: &SettingsPage,
377 window: &mut Window,
378 cx: &mut Context<SettingsWindow>,
379 ) -> Div {
380 v_flex().gap_4().py_4().children(
381 page.items
382 .iter()
383 .map(|item| item.render(self.current_file.clone(), window, cx)),
384 )
385 }
386
387 fn current_page(&self) -> &SettingsPage {
388 &self.pages[self.current_page]
389 }
390
391 fn is_page_selected(&self, ix: usize) -> bool {
392 ix == self.current_page
393 }
394}
395
396impl Render for SettingsWindow {
397 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
398 div()
399 .size_full()
400 .bg(cx.theme().colors().background)
401 .flex()
402 .flex_row()
403 .text_color(cx.theme().colors().text)
404 .child(self.render_nav(window, cx).w(px(300.0)))
405 .child(Divider::vertical().color(ui::DividerColor::BorderVariant))
406 .child(
407 v_flex()
408 .bg(cx.theme().colors().editor_background)
409 .px_6()
410 .py_2()
411 .child(self.render_files(window, cx))
412 .child(self.render_page(self.current_page(), window, cx))
413 .w_full(),
414 )
415 }
416}
417
418fn write_setting_value<T: Send + 'static>(
419 get_value: fn(&mut SettingsContent) -> &mut Option<T>,
420 value: Option<T>,
421 cx: &mut App,
422) {
423 cx.update_global(|store: &mut SettingsStore, cx| {
424 store.update_settings_file(<dyn fs::Fs>::global(cx), move |settings, _cx| {
425 *get_value(settings) = value;
426 });
427 });
428}
429
430fn render_text_field(
431 id: &'static str,
432 _file: SettingsFile,
433 window: &mut Window,
434 cx: &mut App,
435 get_value: fn(&mut SettingsContent) -> &mut Option<String>,
436) -> AnyElement {
437 // TODO: Updating file does not cause the editor text to reload, suspicious it may be a missing global update/notify in SettingsStore
438
439 // TODO: in settings window state
440 let store = SettingsStore::global(cx);
441
442 // TODO: This clone needs to go!!
443 let mut defaults = store.raw_default_settings().clone();
444 let mut user_settings = store
445 .raw_user_settings()
446 .cloned()
447 .unwrap_or_default()
448 .content;
449
450 // TODO: unwrap_or_default here because project name is null
451 let initial_text = get_value(user_settings.as_mut())
452 .clone()
453 .unwrap_or_else(|| get_value(&mut defaults).clone().unwrap_or_default());
454
455 let editor = window.use_keyed_state((id.into(), initial_text.clone()), cx, {
456 move |window, cx| {
457 let mut editor = Editor::single_line(window, cx);
458 editor.set_text(initial_text, window, cx);
459 editor
460 }
461 });
462
463 let weak_editor = editor.downgrade();
464 let theme_colors = cx.theme().colors();
465
466 div()
467 .child(editor)
468 .bg(theme_colors.editor_background)
469 .border_1()
470 .rounded_lg()
471 .border_color(theme_colors.border)
472 .on_action::<menu::Confirm>({
473 move |_, _, cx| {
474 let Some(editor) = weak_editor.upgrade() else {
475 return;
476 };
477 let new_value = editor.read_with(cx, |editor, cx| editor.text(cx));
478 let new_value = (!new_value.is_empty()).then_some(new_value);
479 write_setting_value(get_value, new_value, cx);
480 editor.update(cx, |_, cx| {
481 cx.notify();
482 });
483 }
484 })
485 .into_any_element()
486}
487
488fn render_toggle_button<B: Into<bool> + From<bool> + Copy + Send + 'static>(
489 id: &'static str,
490 _: SettingsFile,
491 cx: &mut App,
492 get_value: fn(&mut SettingsContent) -> &mut Option<B>,
493) -> AnyElement {
494 // TODO: in settings window state
495 let store = SettingsStore::global(cx);
496
497 // TODO: This clone needs to go!!
498 let mut defaults = store.raw_default_settings().clone();
499 let mut user_settings = store
500 .raw_user_settings()
501 .cloned()
502 .unwrap_or_default()
503 .content;
504
505 let toggle_state = if get_value(&mut user_settings)
506 .unwrap_or_else(|| get_value(&mut defaults).unwrap())
507 .into()
508 {
509 ui::ToggleState::Selected
510 } else {
511 ui::ToggleState::Unselected
512 };
513
514 Switch::new(id, toggle_state)
515 .on_click({
516 move |state, _window, cx| {
517 write_setting_value(
518 get_value,
519 Some((*state == ui::ToggleState::Selected).into()),
520 cx,
521 );
522 }
523 })
524 .into_any_element()
525}
526
527fn render_dropdown<T>(
528 id: &'static str,
529 _: SettingsFile,
530 window: &mut Window,
531 cx: &mut App,
532 get_value: fn(&mut SettingsContent) -> &mut Option<T>,
533) -> AnyElement
534where
535 T: strum::VariantArray + strum::VariantNames + Copy + PartialEq + Send + 'static,
536{
537 let variants = || -> &'static [T] { <T as strum::VariantArray>::VARIANTS };
538 let labels = || -> &'static [&'static str] { <T as strum::VariantNames>::VARIANTS };
539
540 let store = SettingsStore::global(cx);
541 let mut defaults = store.raw_default_settings().clone();
542 let mut user_settings = store
543 .raw_user_settings()
544 .cloned()
545 .unwrap_or_default()
546 .content;
547
548 let current_value =
549 get_value(&mut user_settings).unwrap_or_else(|| get_value(&mut defaults).unwrap());
550 let current_value_label =
551 labels()[variants().iter().position(|v| *v == current_value).unwrap()];
552
553 DropdownMenu::new(
554 id,
555 current_value_label,
556 ui::ContextMenu::build(window, cx, move |mut menu, _, _| {
557 for (value, label) in variants()
558 .into_iter()
559 .copied()
560 .zip(labels().into_iter().copied())
561 {
562 menu = menu.toggleable_entry(
563 label,
564 value == current_value,
565 ui::IconPosition::Start,
566 None,
567 move |_, cx| {
568 if value == current_value {
569 return;
570 }
571 write_setting_value(get_value, Some(value), cx);
572 },
573 );
574 }
575 menu
576 }),
577 )
578 .into_any_element()
579}