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>
This commit is contained in:
Mikayla Maki 2025-09-24 08:45:14 -07:00 committed by GitHub
parent 6f3e66d027
commit 53885c00d3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 582 additions and 10 deletions

26
Cargo.lock generated
View file

@ -14502,6 +14502,31 @@ dependencies = [
"zed_actions",
]
[[package]]
name = "settings_ui"
version = "0.1.0"
dependencies = [
"anyhow",
"assets",
"command_palette_hooks",
"editor",
"feature_flags",
"fs",
"futures 0.3.31",
"gpui",
"language",
"menu",
"paths",
"project",
"serde",
"settings",
"theme",
"ui",
"workspace",
"workspace-hack",
"zlog",
]
[[package]]
name = "sha1"
version = "0.10.6"
@ -20253,6 +20278,7 @@ dependencies = [
"session",
"settings",
"settings_profile_selector",
"settings_ui",
"shellexpand 2.1.2",
"smol",
"snippet_provider",

View file

@ -151,6 +151,7 @@ members = [
"crates/settings",
"crates/settings_macros",
"crates/settings_profile_selector",
"crates/settings_ui",
"crates/snippet",
"crates/snippet_provider",
"crates/snippets_ui",

View file

@ -45,6 +45,7 @@ zlog.workspace = true
[dev-dependencies]
fs = { workspace = true, features = ["test-support"] }
gpui = { workspace = true, features = ["test-support"] }
indoc.workspace = true
pretty_assertions.workspace = true
unindent.workspace = true

View file

@ -177,7 +177,6 @@ impl KeymapFile {
}
}
#[cfg(feature = "test-support")]
pub fn load_asset_allow_partial_failure(
asset_path: &str,
cx: &App,

View file

@ -138,7 +138,6 @@ pub struct SettingsLocation<'a> {
pub path: &'a Path,
}
/// A set of strongly-typed setting values defined via multiple config files.
pub struct SettingsStore {
setting_values: HashMap<TypeId, Box<dyn AnySettingValue>>,
default_settings: Rc<SettingsContent>,
@ -318,7 +317,7 @@ impl SettingsStore {
.set_global_value(Box::new(value))
}
/// Get the user's settings as a raw JSON value.
/// Get the user's settings content.
///
/// For user-facing functionality use the typed setting interface.
/// (e.g. ProjectSettings::get_global(cx))
@ -326,6 +325,11 @@ impl SettingsStore {
self.user_settings.as_ref()
}
/// Get the default settings content as a raw JSON value.
pub fn raw_default_settings(&self) -> &SettingsContent {
&self.default_settings
}
/// Get the configured settings profile names.
pub fn configured_settings_profiles(&self) -> impl Iterator<Item = &str> {
self.user_settings

View file

@ -0,0 +1,44 @@
[package]
name = "settings_ui"
version = "0.1.0"
edition.workspace = true
publish.workspace = true
license = "GPL-3.0-or-later"
[lints]
workspace = true
[lib]
path = "src/settings_ui.rs"
[features]
default = []
test-support = []
[dependencies]
project.workspace = true
fs.workspace = true
anyhow.workspace = true
command_palette_hooks.workspace = true
editor.workspace = true
feature_flags.workspace = true
gpui.workspace = true
menu.workspace = true
serde.workspace = true
settings.workspace = true
theme.workspace = true
ui.workspace = true
workspace-hack.workspace = true
workspace.workspace = true
[dev-dependencies]
settings = { workspace = true, features = ["test-support"] }
futures.workspace = true
language.workspace = true
assets.workspace = true
paths.workspace = true
zlog.workspace = true
[[example]]
name = "ui"
path = "examples/ui.rs"

View file

@ -0,0 +1 @@
../../LICENSE-GPL

View file

@ -0,0 +1,48 @@
use std::sync::Arc;
use futures::StreamExt;
use settings::{DEFAULT_KEYMAP_PATH, KeymapFile, SettingsStore, watch_config_file};
use settings_ui::open_settings_editor;
use ui::BorrowAppContext;
fn main() {
let app = gpui::Application::new().with_assets(assets::Assets);
let fs = Arc::new(fs::RealFs::new(None, app.background_executor()));
let mut user_settings_file_rx = watch_config_file(
&app.background_executor(),
fs.clone(),
paths::settings_file().clone(),
);
zlog::init();
zlog::init_output_stderr();
app.run(move |cx| {
<dyn fs::Fs>::set_global(fs.clone(), cx);
settings::init(cx);
theme::init(theme::LoadThemes::JustBase, cx);
workspace::init_settings(cx);
project::Project::init_settings(cx);
language::init(cx);
editor::init(cx);
menu::init();
let keybindings =
KeymapFile::load_asset_allow_partial_failure(DEFAULT_KEYMAP_PATH, cx).unwrap();
cx.bind_keys(keybindings);
cx.spawn(async move |cx| {
while let Some(content) = user_settings_file_rx.next().await {
cx.update(|cx| {
cx.update_global(|store: &mut SettingsStore, cx| {
store.set_user_settings(&content, cx).unwrap()
})
})
.ok();
}
})
.detach();
open_settings_editor(cx).unwrap();
cx.activate(true);
});
}

View file

@ -0,0 +1,446 @@
//! # 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()
}

View file

@ -19,11 +19,11 @@ name = "zed"
path = "src/main.rs"
[dependencies]
activity_indicator.workspace = true
acp_tools.workspace = true
activity_indicator.workspace = true
agent.workspace = true
agent_ui.workspace = true
agent_settings.workspace = true
agent_ui.workspace = true
anyhow.workspace = true
askpass.workspace = true
assets.workspace = true
@ -60,13 +60,13 @@ extensions_ui.workspace = true
feature_flags.workspace = true
feedback.workspace = true
file_finder.workspace = true
system_specs.workspace = true
fs.workspace = true
futures.workspace = true
git.workspace = true
git_hosting_providers.workspace = true
git_ui.workspace = true
go_to_line.workspace = true
system_specs.workspace = true
gpui = { workspace = true, features = [
"wayland",
"x11",
@ -75,12 +75,13 @@ gpui = { workspace = true, features = [
] }
gpui_tokio.workspace = true
edit_prediction_button.workspace = true
http_client.workspace = true
image_viewer.workspace = true
edit_prediction_button.workspace = true
inspector_ui.workspace = true
install_cli.workspace = true
journal.workspace = true
keymap_editor.workspace = true
language.workspace = true
language_extension.workspace = true
language_model.workspace = true
@ -93,7 +94,6 @@ line_ending_selector.workspace = true
log.workspace = true
markdown.workspace = true
markdown_preview.workspace = true
svg_preview.workspace = true
menu.workspace = true
migrator.workspace = true
mimalloc = { version = "0.1", optional = true }
@ -107,7 +107,6 @@ outline_panel.workspace = true
parking_lot.workspace = true
paths.workspace = true
picker.workspace = true
settings_profile_selector.workspace = true
profiling.workspace = true
project.workspace = true
project_panel.workspace = true
@ -126,12 +125,14 @@ serde.workspace = true
serde_json.workspace = true
session.workspace = true
settings.workspace = true
keymap_editor.workspace = true
settings_profile_selector.workspace = true
settings_ui.workspace = true
shellexpand.workspace = true
smol.workspace = true
snippet_provider.workspace = true
snippets_ui.workspace = true
supermaven.workspace = true
svg_preview.workspace = true
sysinfo.workspace = true
tab_switcher.workspace = true
task.workspace = true

View file

@ -614,6 +614,7 @@ pub fn main() {
markdown_preview::init(cx);
svg_preview::init(cx);
onboarding::init(cx);
settings_ui::init(cx);
keymap_editor::init(cx);
extensions_ui::init(cx);
zeta::init(cx);