settings_ui.rs

  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}