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}