settings_ui.rs

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