settings_ui: Language settings UI (#39640)

Closes #ISSUE

Release Notes:

- N/A *or* Added/Fixed/Improved ...
This commit is contained in:
Ben Kunkle
2025-10-06 19:56:23 -04:00
committed by GitHub
parent a2a7bd139a
commit c1e917165d
5 changed files with 1477 additions and 135 deletions
+9 -11
View File
@@ -1876,21 +1876,19 @@
// Allows to enable/disable formatting with Prettier
// and configure default Prettier, used when no project-level Prettier installation is found.
"prettier": {
// // Whether to consider prettier formatter or not when attempting to format a file.
"allowed": false
//
// // Use regular Prettier json configuration.
// // If Prettier is allowed, Zed will use this for its Prettier instance for any applicable file, if
// // the project has no other Prettier installed.
// "plugins": [],
//
// // Use regular Prettier json configuration.
// // If Prettier is allowed, Zed will use this for its Prettier instance for any applicable file, if
// // the project has no other Prettier installed.
// Enables or disables formatting with Prettier for any given language.
"allowed": false,
// Forces Prettier integration to use a specific parser name when formatting files with the language.
"plugins": [],
// Default Prettier options, in the format as in package.json section for Prettier.
// If project installs Prettier via its package.json, these options will be ignored.
// "trailingComma": "es5",
// "tabWidth": 4,
// "semi": false,
// "singleQuote": true
// Forces Prettier integration to use a specific parser name when formatting files with the language
// when set to a non-empty string.
"parser": ""
},
// Settings for auto-closing of JSX tags.
"jsx_tag_auto_close": {
+5 -5
View File
@@ -533,9 +533,9 @@ impl settings::Settings for AllLanguageSettings {
formatter: settings.formatter.unwrap(),
prettier: PrettierSettings {
allowed: prettier.allowed.unwrap(),
parser: prettier.parser,
plugins: prettier.plugins,
options: prettier.options,
parser: prettier.parser.filter(|parser| !parser.is_empty()),
plugins: prettier.plugins.unwrap_or_default(),
options: prettier.options.unwrap_or_default(),
},
jsx_tag_auto_close: settings.jsx_tag_auto_close.unwrap().enabled.unwrap(),
enable_language_server: settings.enable_language_server.unwrap(),
@@ -571,7 +571,7 @@ impl settings::Settings for AllLanguageSettings {
code_actions_on_format: settings.code_actions_on_format.unwrap(),
linked_edits: settings.linked_edits.unwrap(),
tasks: LanguageTaskSettings {
variables: tasks.variables,
variables: tasks.variables.unwrap_or_default(),
enabled: tasks.enabled.unwrap(),
prefer_lsp: tasks.prefer_lsp.unwrap(),
},
@@ -579,7 +579,7 @@ impl settings::Settings for AllLanguageSettings {
show_completion_documentation: settings.show_completion_documentation.unwrap(),
completions: CompletionSettings {
words: completions.words.unwrap(),
words_min_length: completions.words_min_length.unwrap(),
words_min_length: completions.words_min_length.unwrap() as usize,
lsp: completions.lsp.unwrap(),
lsp_fetch_timeout_ms: completions.lsp_fetch_timeout_ms.unwrap(),
lsp_insert_mode: completions.lsp_insert_mode.unwrap(),
+103 -13
View File
@@ -385,7 +385,19 @@ pub struct WhitespaceMapContent {
}
/// The behavior of `editor::Rewrap`.
#[derive(Debug, PartialEq, Clone, Copy, Default, Serialize, Deserialize, JsonSchema, MergeFrom)]
#[derive(
Debug,
PartialEq,
Clone,
Copy,
Default,
Serialize,
Deserialize,
JsonSchema,
MergeFrom,
strum::VariantArray,
strum::VariantNames,
)]
#[serde(rename_all = "snake_case")]
pub enum RewrapBehavior {
/// Only rewrap within comments.
@@ -500,7 +512,7 @@ pub struct CompletionSettingsContent {
/// Before that value, it's still possible to trigger the words-based completion manually with the corresponding editor command.
///
/// Default: 3
pub words_min_length: Option<usize>,
pub words_min_length: Option<u32>,
/// Whether to fetch LSP completions or not.
///
/// Default: true
@@ -516,7 +528,19 @@ pub struct CompletionSettingsContent {
pub lsp_insert_mode: Option<LspInsertMode>,
}
#[derive(Copy, Clone, Debug, Serialize, Deserialize, PartialEq, Eq, JsonSchema, MergeFrom)]
#[derive(
Copy,
Clone,
Debug,
Serialize,
Deserialize,
PartialEq,
Eq,
JsonSchema,
MergeFrom,
strum::VariantArray,
strum::VariantNames,
)]
#[serde(rename_all = "snake_case")]
pub enum LspInsertMode {
/// Replaces text before the cursor, using the `insert` range described in the LSP specification.
@@ -532,7 +556,19 @@ pub enum LspInsertMode {
}
/// Controls how document's words are completed.
#[derive(Copy, Clone, Debug, Serialize, Deserialize, PartialEq, Eq, JsonSchema, MergeFrom)]
#[derive(
Copy,
Clone,
Debug,
Serialize,
Deserialize,
PartialEq,
Eq,
JsonSchema,
MergeFrom,
strum::VariantArray,
strum::VariantNames,
)]
#[serde(rename_all = "snake_case")]
pub enum WordsCompletionMode {
/// Always fetch document's words for completions along with LSP completions.
@@ -559,17 +595,29 @@ pub struct PrettierSettingsContent {
/// Forces Prettier integration to use specific plugins when formatting files with the language.
/// The default Prettier will be installed with these plugins.
#[serde(default)]
pub plugins: HashSet<String>,
pub plugins: Option<HashSet<String>>,
/// Default Prettier options, in the format as in package.json section for Prettier.
/// If project installs Prettier via its package.json, these options will be ignored.
#[serde(flatten)]
pub options: HashMap<String, serde_json::Value>,
pub options: Option<HashMap<String, serde_json::Value>>,
}
/// TODO: this should just be a bool
/// Controls the behavior of formatting files when they are saved.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, MergeFrom)]
#[derive(
Debug,
Clone,
Copy,
PartialEq,
Eq,
Serialize,
Deserialize,
JsonSchema,
MergeFrom,
strum::VariantArray,
strum::VariantNames,
)]
#[serde(rename_all = "lowercase")]
pub enum FormatOnSave {
/// Files should be formatted on save.
@@ -745,11 +793,10 @@ pub struct IndentGuideSettingsContent {
/// The task settings for a particular language.
#[skip_serializing_none]
#[derive(Debug, Clone, Deserialize, PartialEq, Serialize, JsonSchema, MergeFrom)]
#[derive(Debug, Clone, Default, Deserialize, PartialEq, Serialize, JsonSchema, MergeFrom)]
pub struct LanguageTaskSettingsContent {
/// Extra task variables to set for a particular language.
#[serde(default)]
pub variables: HashMap<String, String>,
pub variables: Option<HashMap<String, String>>,
pub enabled: Option<bool>,
/// Use LSP tasks over Zed language extension ones.
/// If no LSP tasks are returned due to error/timeout or regular execution,
@@ -768,7 +815,18 @@ pub struct LanguageToSettingsMap(pub HashMap<SharedString, LanguageSettingsConte
/// Determines how indent guides are colored.
#[derive(
Default, Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, MergeFrom,
Default,
Debug,
Copy,
Clone,
PartialEq,
Eq,
Serialize,
Deserialize,
JsonSchema,
MergeFrom,
strum::VariantArray,
strum::VariantNames,
)]
#[serde(rename_all = "snake_case")]
pub enum IndentGuideColoring {
@@ -783,7 +841,18 @@ pub enum IndentGuideColoring {
/// Determines how indent guide backgrounds are colored.
#[derive(
Default, Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, MergeFrom,
Default,
Debug,
Copy,
Clone,
PartialEq,
Eq,
Serialize,
Deserialize,
JsonSchema,
MergeFrom,
strum::VariantArray,
strum::VariantNames,
)]
#[serde(rename_all = "snake_case")]
pub enum IndentGuideBackgroundColoring {
@@ -836,4 +905,25 @@ mod test {
let result: Result<LanguageSettingsContent, _> = serde_json::from_str(raw_auto);
assert!(result.is_err());
}
#[test]
fn test_prettier_options() {
let raw_prettier = r#"{"allowed": false, "tabWidth": 4, "semi": false}"#;
let result = serde_json::from_str::<PrettierSettingsContent>(raw_prettier)
.expect("Failed to parse prettier options");
assert!(
result
.options
.as_ref()
.expect("options were flattened")
.contains_key("semi")
);
assert!(
result
.options
.as_ref()
.expect("options were flattened")
.contains_key("tabWidth")
);
}
}
File diff suppressed because it is too large Load Diff
+124 -75
View File
@@ -23,7 +23,7 @@ use std::{
num::NonZeroU32,
ops::Range,
rc::Rc,
sync::{Arc, atomic::AtomicBool},
sync::{Arc, LazyLock, RwLock, atomic::AtomicBool},
};
use ui::{
ButtonLike, ContextMenu, Divider, DropdownMenu, DropdownStyle, IconButtonShape, PopoverMenu,
@@ -309,6 +309,26 @@ fn init_renderers(cx: &mut App) {
.add_renderer::<settings::ShowCloseButton>(|settings_field, file, _, window, cx| {
render_dropdown(*settings_field, file, window, cx)
})
.add_renderer::<settings::RewrapBehavior>(|settings_field, file, _, window, cx| {
render_dropdown(*settings_field, file, window, cx)
})
.add_renderer::<settings::FormatOnSave>(|settings_field, file, _, window, cx| {
render_dropdown(*settings_field, file, window, cx)
})
.add_renderer::<settings::IndentGuideColoring>(|settings_field, file, _, window, cx| {
render_dropdown(*settings_field, file, window, cx)
})
.add_renderer::<settings::IndentGuideBackgroundColoring>(
|settings_field, file, _, window, cx| {
render_dropdown(*settings_field, file, window, cx)
},
)
.add_renderer::<settings::WordsCompletionMode>(|settings_field, file, _, window, cx| {
render_dropdown(*settings_field, file, window, cx)
})
.add_renderer::<settings::LspInsertMode>(|settings_field, file, _, window, cx| {
render_dropdown(*settings_field, file, window, cx)
})
.add_renderer::<f32>(|settings_field, file, _, window, cx| {
render_numeric_stepper(*settings_field, file, window, cx)
})
@@ -353,6 +373,26 @@ pub fn open_settings_editor(cx: &mut App) -> anyhow::Result<WindowHandle<Setting
)
}
/// The current sub page path that is selected.
/// If this is empty the selected page is rendered,
/// otherwise the last sub page gets rendered.
///
/// Global so that `pick` and `pick_mut` callbacks can access it
/// and use it to dynamically render sub pages (e.g. for language settings)
static SUB_PAGE_STACK: LazyLock<RwLock<Vec<SubPage>>> = LazyLock::new(|| RwLock::new(Vec::new()));
fn sub_page_stack() -> std::sync::RwLockReadGuard<'static, Vec<SubPage>> {
SUB_PAGE_STACK
.read()
.expect("SUB_PAGE_STACK is never poisoned")
}
fn sub_page_stack_mut() -> std::sync::RwLockWriteGuard<'static, Vec<SubPage>> {
SUB_PAGE_STACK
.write()
.expect("SUB_PAGE_STACK is never poisoned")
}
pub struct SettingsWindow {
files: Vec<SettingsUiFile>,
current_file: SettingsUiFile,
@@ -363,10 +403,6 @@ pub struct SettingsWindow {
navbar_entries: Vec<NavBarEntry>,
list_handle: UniformListScrollHandle,
search_matches: Vec<Vec<bool>>,
/// The current sub page path that is selected.
/// If this is empty the selected page is rendered,
/// otherwise the last sub page gets rendered.
sub_page_stack: Vec<SubPage>,
scroll_handle: ScrollHandle,
}
@@ -548,7 +584,12 @@ impl PartialEq for SettingItem {
#[derive(Clone)]
struct SubPageLink {
title: &'static str,
render: Rc<dyn Fn(&mut SettingsWindow, &mut Window, &mut App) -> AnyElement>,
render: Arc<
dyn Fn(&mut SettingsWindow, &mut Window, &mut Context<SettingsWindow>) -> AnyElement
+ 'static
+ Send
+ Sync,
>,
}
impl PartialEq for SubPageLink {
@@ -647,7 +688,6 @@ impl SettingsWindow {
search_bar,
search_task: None,
search_matches: vec![],
sub_page_stack: vec![],
scroll_handle: ScrollHandle::new(),
};
@@ -978,7 +1018,7 @@ impl SettingsWindow {
let mut items = vec![];
items.push(self.current_page().title);
items.extend(
self.sub_page_stack
sub_page_stack()
.iter()
.flat_map(|page| [page.section_header, page.link.title]),
);
@@ -995,6 +1035,73 @@ impl SettingsWindow {
.child(Label::new(last))
}
fn render_page_items<'a, Items: Iterator<Item = &'a SettingsPageItem>>(
&self,
items: Items,
window: &mut Window,
cx: &mut Context<SettingsWindow>,
) -> impl IntoElement {
let mut page_content = v_flex()
.id("settings-ui-page")
.size_full()
.gap_4()
.overflow_y_scroll()
.track_scroll(&self.scroll_handle);
let items: Vec<_> = items.collect();
let items_len = items.len();
let mut section_header = None;
let has_active_search = !self.search_bar.read(cx).is_empty(cx);
let has_no_results = items_len == 0 && has_active_search;
if has_no_results {
let search_query = self.search_bar.read(cx).text(cx);
page_content = page_content.child(
v_flex()
.size_full()
.items_center()
.justify_center()
.gap_1()
.child(div().child("No Results"))
.child(
div()
.text_sm()
.text_color(cx.theme().colors().text_muted)
.child(format!("No settings match \"{}\"", search_query)),
),
)
} else {
let last_non_header_index = items
.iter()
.enumerate()
.rev()
.find(|(_, item)| !matches!(item, SettingsPageItem::SectionHeader(_)))
.map(|(index, _)| index);
page_content =
page_content.children(items.clone().into_iter().enumerate().map(|(index, item)| {
let no_bottom_border = items
.get(index + 1)
.map(|next_item| matches!(next_item, SettingsPageItem::SectionHeader(_)))
.unwrap_or(false);
let is_last = Some(index) == last_non_header_index;
if let SettingsPageItem::SectionHeader(header) = item {
section_header = Some(*header);
}
item.render(
self.current_file.clone(),
section_header.expect("All items rendered after a section header"),
no_bottom_border || is_last,
window,
cx,
)
}))
}
page_content
}
fn render_page(
&mut self,
window: &mut Window,
@@ -1009,70 +1116,13 @@ impl SettingsWindow {
.bg(cx.theme().colors().editor_background)
.vertical_scrollbar_for(self.scroll_handle.clone(), window, cx);
let mut page_content = v_flex()
.id("settings-ui-page")
.size_full()
.gap_4()
.overflow_y_scroll()
.track_scroll(&self.scroll_handle);
let page_content;
if self.sub_page_stack.len() == 0 {
if sub_page_stack().len() == 0 {
page = page.child(self.render_files(window, cx));
let items: Vec<_> = self.page_items().collect();
let items_len = items.len();
let mut section_header = None;
let search_query = self.search_bar.read(cx).text(cx);
let has_active_search = !search_query.is_empty();
let has_no_results = items_len == 0 && has_active_search;
if has_no_results {
page_content = page_content.child(
v_flex()
.size_full()
.items_center()
.justify_center()
.gap_1()
.child(div().child("No Results"))
.child(
div()
.text_sm()
.text_color(cx.theme().colors().text_muted)
.child(format!("No settings match \"{}\"", search_query)),
),
)
} else {
let last_non_header_index = items
.iter()
.enumerate()
.rev()
.find(|(_, item)| !matches!(item, SettingsPageItem::SectionHeader(_)))
.map(|(index, _)| index);
page_content = page_content.children(items.clone().into_iter().enumerate().map(
|(index, item)| {
let no_bottom_border = items
.get(index + 1)
.map(|next_item| {
matches!(next_item, SettingsPageItem::SectionHeader(_))
})
.unwrap_or(false);
let is_last = Some(index) == last_non_header_index;
if let SettingsPageItem::SectionHeader(header) = item {
section_header = Some(*header);
}
item.render(
self.current_file.clone(),
section_header.expect("All items rendered after a section header"),
no_bottom_border || is_last,
window,
cx,
)
},
))
}
page_content = self
.render_page_items(self.page_items(), window, cx)
.into_any_element();
} else {
page = page.child(
h_flex()
@@ -1089,8 +1139,8 @@ impl SettingsWindow {
.child(self.render_sub_page_breadcrumbs()),
);
let active_page_render_fn = self.sub_page_stack.last().unwrap().link.render.clone();
page_content = page_content.child((active_page_render_fn)(self, window, cx));
let active_page_render_fn = sub_page_stack().last().unwrap().link.render.clone();
page_content = (active_page_render_fn)(self, window, cx);
}
return page.child(page_content);
@@ -1122,7 +1172,7 @@ impl SettingsWindow {
section_header: &'static str,
cx: &mut Context<SettingsWindow>,
) {
self.sub_page_stack.push(SubPage {
sub_page_stack_mut().push(SubPage {
link: sub_page_link,
section_header,
});
@@ -1130,7 +1180,7 @@ impl SettingsWindow {
}
fn pop_sub_page(&mut self, cx: &mut Context<SettingsWindow>) {
self.sub_page_stack.pop();
sub_page_stack_mut().pop();
cx.notify();
}
}
@@ -1558,7 +1608,6 @@ mod test {
list_handle: UniformListScrollHandle::default(),
search_matches: vec![],
search_task: None,
sub_page_stack: vec![],
scroll_handle: ScrollHandle::new(),
};