Files
oak-gpui/crates/settings_ui/src/settings_ui.rs
T
53885c00d3 Start up settings UI 2 (#38673)
Release Notes:

- N/A

---------

Co-authored-by: Anthony <hello@anthonyeid.me>
Co-authored-by: Ben Kunkle <ben@zed.dev>
Co-authored-by: Anthony <anthony@zed.dev>
Co-authored-by: Ben Kunkle <ben.kunkle@gmail.com>
2025-09-24 15:45:14 +00:00

447 lines
14 KiB
Rust

//! # settings_ui
use std::{rc::Rc, sync::Arc};
use editor::Editor;
use feature_flags::{FeatureFlag, FeatureFlagAppExt as _};
use gpui::{
App, AppContext as _, Context, Div, Entity, IntoElement, ReadGlobal as _, Render, Window,
WindowHandle, WindowOptions, actions, div, px, size,
};
use project::WorktreeId;
use settings::{SettingsContent, SettingsStore};
use std::path::Path;
use ui::{
ActiveTheme as _, AnyElement, BorrowAppContext as _, Button, Clickable as _, Color,
FluentBuilder as _, Icon, IconName, InteractiveElement as _, Label, LabelCommon as _,
LabelSize, ParentElement, SharedString, StatefulInteractiveElement as _, Styled, Switch,
v_flex,
};
fn user_settings_data() -> Vec<SettingsPage> {
vec![
SettingsPage {
title: "General Page",
items: vec![
SettingsPageItem::SectionHeader("General Section"),
SettingsPageItem::SettingItem(SettingItem {
title: "Confirm Quit",
description: "Whether to confirm before quitting Zed",
render: Rc::new(|_, cx| {
render_toggle_button(
"confirm_quit",
SettingsFile::User,
cx,
|settings_content| &mut settings_content.workspace.confirm_quit,
)
}),
}),
SettingsPageItem::SettingItem(SettingItem {
title: "Auto Update",
description: "Automatically update Zed (may be ignored on Linux if installed through a package manager)",
render: Rc::new(|_, cx| {
render_toggle_button(
"Auto Update",
SettingsFile::User,
cx,
|settings_content| &mut settings_content.auto_update,
)
}),
}),
],
},
SettingsPage {
title: "Project",
items: vec![
SettingsPageItem::SectionHeader("Worktree Settings Content"),
SettingsPageItem::SettingItem(SettingItem {
title: "Project Name",
description: "The displayed name of this project. If not set, the root directory name",
render: Rc::new(|window, cx| {
render_text_field(
"project_name",
SettingsFile::User,
window,
cx,
|settings_content| &mut settings_content.project.worktree.project_name,
)
}),
}),
],
},
]
}
fn project_settings_data() -> Vec<SettingsPage> {
vec![SettingsPage {
title: "Project",
items: vec![
SettingsPageItem::SectionHeader("Worktree Settings Content"),
SettingsPageItem::SettingItem(SettingItem {
title: "Project Name",
description: " The displayed name of this project. If not set, the root directory name",
render: Rc::new(|window, cx| {
render_text_field(
"project_name",
SettingsFile::Local((
WorktreeId::from_usize(0),
Arc::from(Path::new("TODO: actually pass through file")),
)),
window,
cx,
|settings_content| &mut settings_content.project.worktree.project_name,
)
}),
}),
],
}]
}
pub struct SettingsUiFeatureFlag;
impl FeatureFlag for SettingsUiFeatureFlag {
const NAME: &'static str = "settings-ui";
}
actions!(
zed,
[
/// Opens Settings Editor.
OpenSettingsEditor
]
);
pub fn init(cx: &mut App) {
cx.observe_new(|workspace: &mut workspace::Workspace, _, _| {
workspace.register_action_renderer(|div, _, _, cx| {
let settings_ui_actions = [std::any::TypeId::of::<OpenSettingsEditor>()];
let has_flag = cx.has_flag::<SettingsUiFeatureFlag>();
command_palette_hooks::CommandPaletteFilter::update_global(cx, |filter, _| {
if has_flag {
filter.show_action_types(&settings_ui_actions);
} else {
filter.hide_action_types(&settings_ui_actions);
}
});
if has_flag {
div.on_action(cx.listener(|_, _: &OpenSettingsEditor, _, cx| {
open_settings_editor(cx).ok();
}))
} else {
div
}
});
})
.detach();
}
pub fn open_settings_editor(cx: &mut App) -> anyhow::Result<WindowHandle<SettingsWindow>> {
cx.open_window(
WindowOptions {
titlebar: None,
focus: true,
show: true,
kind: gpui::WindowKind::Normal,
window_min_size: Some(size(px(300.), px(500.))), // todo(settings_ui): Does this min_size make sense?
..Default::default()
},
|window, cx| cx.new(|cx| SettingsWindow::new(window, cx)),
)
}
pub struct SettingsWindow {
files: Vec<SettingsFile>,
current_file: SettingsFile,
pages: Vec<SettingsPage>,
search: Entity<Editor>,
current_page: usize, // Index into pages - should probably be (usize, Option<usize>) for section + page
}
#[derive(Clone)]
struct SettingsPage {
title: &'static str,
items: Vec<SettingsPageItem>,
}
#[derive(Clone)]
enum SettingsPageItem {
SectionHeader(&'static str),
SettingItem(SettingItem),
}
impl SettingsPageItem {
fn render(&self, window: &mut Window, cx: &mut App) -> AnyElement {
match self {
SettingsPageItem::SectionHeader(header) => Label::new(SharedString::new_static(header))
.size(LabelSize::Large)
.into_any_element(),
SettingsPageItem::SettingItem(setting_item) => div()
.child(setting_item.title)
.child(setting_item.description)
.child((setting_item.render)(window, cx))
.into_any_element(),
}
}
}
impl SettingsPageItem {
fn _header(&self) -> Option<&'static str> {
match self {
SettingsPageItem::SectionHeader(header) => Some(header),
_ => None,
}
}
}
#[derive(Clone)]
struct SettingItem {
title: &'static str,
description: &'static str,
render: std::rc::Rc<dyn Fn(&mut Window, &mut App) -> AnyElement>,
}
#[allow(unused)]
#[derive(Clone)]
enum SettingsFile {
User, // Uses all settings.
Local((WorktreeId, Arc<Path>)), // Has a special name, and special set of settings
Server(&'static str), // Uses a special name, and the user settings
}
impl SettingsFile {
fn pages(&self) -> Vec<SettingsPage> {
match self {
SettingsFile::User => user_settings_data(),
SettingsFile::Local(_) => project_settings_data(),
SettingsFile::Server(_) => user_settings_data(),
}
}
fn name(&self) -> String {
match self {
SettingsFile::User => "User".to_string(),
SettingsFile::Local((_, path)) => format!("Local ({})", path.display()),
SettingsFile::Server(file) => format!("Server ({})", file),
}
}
}
impl SettingsWindow {
pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
let current_file = SettingsFile::User;
let search = cx.new(|cx| {
let mut editor = Editor::single_line(window, cx);
editor.set_placeholder_text("Search Settings", window, cx);
editor
});
let mut this = Self {
files: vec![
SettingsFile::User,
SettingsFile::Local((
WorktreeId::from_usize(0),
Arc::from(Path::new("/my-project/")),
)),
],
current_file: current_file,
pages: vec![],
current_page: 0,
search,
};
cx.observe_global_in::<SettingsStore>(window, move |_, _, cx| {
cx.notify();
})
.detach();
this.build_ui();
this
}
fn build_ui(&mut self) {
self.pages = self.current_file.pages();
}
fn change_file(&mut self, ix: usize) {
self.current_file = self.files[ix].clone();
self.build_ui();
}
fn render_files(&self, _window: &mut Window, cx: &mut Context<SettingsWindow>) -> Div {
div()
.flex()
.flex_row()
.gap_1()
.children(self.files.iter().enumerate().map(|(ix, file)| {
Button::new(ix, file.name())
.on_click(cx.listener(move |this, _, _window, _cx| this.change_file(ix)))
}))
}
fn render_search(&self, _window: &mut Window, _cx: &mut App) -> Div {
div()
.child(Icon::new(IconName::MagnifyingGlass))
.child(self.search.clone())
}
fn render_nav(&self, window: &mut Window, cx: &mut Context<SettingsWindow>) -> Div {
let mut nav = v_flex()
.p_4()
.gap_2()
.child(div().h_10()) // Files spacer;
.child(self.render_search(window, cx));
for (ix, page) in self.pages.iter().enumerate() {
nav = nav.child(
div()
.id(page.title)
.child(
Label::new(page.title)
.size(LabelSize::Large)
.when(self.is_page_selected(ix), |this| {
this.color(Color::Selected)
}),
)
.on_click(cx.listener(move |this, _, _, cx| {
this.current_page = ix;
cx.notify();
})),
);
}
nav
}
fn render_page(
&self,
page: &SettingsPage,
window: &mut Window,
cx: &mut Context<SettingsWindow>,
) -> Div {
div()
.child(self.render_files(window, cx))
.child(Label::new(page.title))
.children(page.items.iter().map(|item| item.render(window, cx)))
}
fn current_page(&self) -> &SettingsPage {
&self.pages[self.current_page]
}
fn is_page_selected(&self, ix: usize) -> bool {
ix == self.current_page
}
}
impl Render for SettingsWindow {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
div()
.size_full()
.bg(cx.theme().colors().background)
.flex()
.flex_row()
.text_color(cx.theme().colors().text)
.child(self.render_nav(window, cx).w(px(300.0)))
.child(self.render_page(self.current_page(), window, cx).w_full())
}
}
fn write_setting_value<T: Send + 'static>(
get_value: fn(&mut SettingsContent) -> &mut Option<T>,
value: Option<T>,
cx: &mut App,
) {
cx.update_global(|store: &mut SettingsStore, cx| {
store.update_settings_file(<dyn fs::Fs>::global(cx), move |settings, _cx| {
*get_value(settings) = value;
});
});
}
fn render_text_field(
id: &'static str,
_file: SettingsFile,
window: &mut Window,
cx: &mut App,
get_value: fn(&mut SettingsContent) -> &mut Option<String>,
) -> AnyElement {
// TODO: Updating file does not cause the editor text to reload, suspicious it may be a missing global update/notify in SettingsStore
// TODO: in settings window state
let store = SettingsStore::global(cx);
// TODO: This clone needs to go!!
let mut defaults = store.raw_default_settings().clone();
let mut user_settings = store
.raw_user_settings()
.cloned()
.unwrap_or_default()
.content;
// TODO: unwrap_or_default here because project name is null
let initial_text = get_value(user_settings.as_mut())
.clone()
.unwrap_or_else(|| get_value(&mut defaults).clone().unwrap_or_default());
let editor = window.use_keyed_state((id.into(), initial_text.clone()), cx, {
move |window, cx| {
let mut editor = Editor::single_line(window, cx);
editor.set_text(initial_text, window, cx);
editor
}
});
let weak_editor = editor.downgrade();
let theme_colors = cx.theme().colors();
div()
.child(editor)
.bg(theme_colors.editor_background)
.border_1()
.rounded_lg()
.border_color(theme_colors.border)
.on_action::<menu::Confirm>({
move |_, _, cx| {
let Some(editor) = weak_editor.upgrade() else {
return;
};
let new_value = editor.read_with(cx, |editor, cx| editor.text(cx));
let new_value = (!new_value.is_empty()).then_some(new_value);
write_setting_value(get_value, new_value, cx);
editor.update(cx, |_, cx| {
cx.notify();
});
}
})
.into_any_element()
}
fn render_toggle_button(
id: &'static str,
_: SettingsFile,
cx: &mut App,
get_value: fn(&mut SettingsContent) -> &mut Option<bool>,
) -> AnyElement {
// TODO: in settings window state
let store = SettingsStore::global(cx);
// TODO: This clone needs to go!!
let mut defaults = store.raw_default_settings().clone();
let mut user_settings = store
.raw_user_settings()
.cloned()
.unwrap_or_default()
.content;
let toggle_state =
if get_value(&mut user_settings).unwrap_or_else(|| get_value(&mut defaults).unwrap()) {
ui::ToggleState::Selected
} else {
ui::ToggleState::Unselected
};
Switch::new(id, toggle_state)
.on_click({
move |state, _window, cx| {
write_setting_value(get_value, Some(*state == ui::ToggleState::Selected), cx);
}
})
.into_any_element()
}