From 473100038892e48b7eedc04845b728619cad83cf Mon Sep 17 00:00:00 2001 From: Ben Kunkle Date: Tue, 20 Jan 2026 13:29:55 -0600 Subject: [PATCH] settings_ui: Remote project setting files support (#45292) Closes #ISSUE Release Notes: - settings_ui: Added support for viewing and updating project settings files in remote projects --------- Co-authored-by: Mikayla --- Cargo.lock | 3 +- crates/project/Cargo.toml | 1 - crates/project/src/project.rs | 44 +- crates/settings_ui/Cargo.toml | 12 +- crates/settings_ui/src/components/dropdown.rs | 8 +- .../settings_ui/src/components/font_picker.rs | 10 +- .../src/components/icon_theme_picker.rs | 47 +- .../settings_ui/src/components/input_field.rs | 11 +- .../src/components/theme_picker.rs | 10 +- .../pages/edit_prediction_provider_setup.rs | 2 +- crates/settings_ui/src/settings_ui.rs | 727 ++++++++++++++++-- 11 files changed, 711 insertions(+), 164 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b64a37844cb3368abb3b845e37829ee48df03554..ffcaa4106e9b82d00fca7a2fdba05ebd29325055 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -12582,7 +12582,6 @@ dependencies = [ "dap", "dap_adapters", "db", - "encoding_rs", "extension", "fancy-regex", "fs", @@ -15045,10 +15044,12 @@ dependencies = [ "picker", "pretty_assertions", "project", + "recent_projects", "release_channel", "schemars", "search", "serde", + "serde_json", "session", "settings", "strum 0.27.2", diff --git a/crates/project/Cargo.toml b/crates/project/Cargo.toml index e7480172a2322c4b296be65b3627735b70b3f9e0..040ca841afe237d40422d58b8238bcc0b18ada78 100644 --- a/crates/project/Cargo.toml +++ b/crates/project/Cargo.toml @@ -41,7 +41,6 @@ clock.workspace = true collections.workspace = true context_server.workspace = true dap.workspace = true -encoding_rs.workspace = true extension.workspace = true fancy-regex.workspace = true fs.workspace = true diff --git a/crates/project/src/project.rs b/crates/project/src/project.rs index 870a578e372494c3c758772c3b5429c8e3f5bd17..c279d3d89f8825be83fb6f0ea79c5b0591869948 100644 --- a/crates/project/src/project.rs +++ b/crates/project/src/project.rs @@ -72,7 +72,7 @@ use debugger::{ dap_store::{DapStore, DapStoreEvent}, session::Session, }; -use encoding_rs; + pub use environment::ProjectEnvironment; #[cfg(test)] use futures::future::join_all; @@ -5596,48 +5596,6 @@ impl Project { worktree.read(cx).entry_for_path(rel_path).is_some() }) } - - pub fn update_local_settings_file( - &self, - worktree_id: WorktreeId, - rel_path: Arc, - cx: &mut App, - update: impl 'static + Send + FnOnce(&mut settings::SettingsContent, &App), - ) { - let Some(worktree) = self.worktree_for_id(worktree_id, cx) else { - // todo(settings_ui) error? - return; - }; - cx.spawn(async move |cx| { - let file = worktree - .update(cx, |worktree, cx| worktree.load_file(&rel_path, cx)) - .await - .context("Failed to load settings file")?; - - let has_bom = file.has_bom; - - let new_text = cx.read_global::(|store, cx| { - store.new_text_for_update(file.text, move |settings| update(settings, cx)) - }); - worktree - .update(cx, |worktree, cx| { - let line_ending = text::LineEnding::detect(&new_text); - worktree.write_file( - rel_path.clone(), - new_text.into(), - line_ending, - encoding_rs::UTF_8, - has_bom, - cx, - ) - }) - .await - .context("Failed to write settings file")?; - - anyhow::Ok(()) - }) - .detach_and_log_err(cx); - } } pub struct PathMatchCandidateSet { diff --git a/crates/settings_ui/Cargo.toml b/crates/settings_ui/Cargo.toml index 70f2b044e67f674ec714076d19d7e3f3dc9ba18a..6d65513d250876e901bc624ba1104767358a854b 100644 --- a/crates/settings_ui/Cargo.toml +++ b/crates/settings_ui/Cargo.toml @@ -20,14 +20,16 @@ anyhow.workspace = true bm25 = "2.3.2" copilot_ui.workspace = true edit_prediction.workspace = true -language_models.workspace = true editor.workspace = true feature_flags.workspace = true fs.workspace = true +futures.workspace = true fuzzy.workspace = true gpui.workspace = true heck.workspace = true itertools.workspace = true +language_models.workspace = true +language.workspace = true log.workspace = true menu.workspace = true paths.workspace = true @@ -51,12 +53,18 @@ zed_actions.workspace = true [dev-dependencies] assets.workspace = true client.workspace = true +fs = { workspace = true, features = ["test-support"] } futures.workspace = true gpui = { workspace = true, features = ["test-support"] } language.workspace = true node_runtime.workspace = true paths.workspace = true pretty_assertions.workspace = true +project = { workspace = true, features = ["test-support"] } +recent_projects = { workspace = true, features = ["test-support"] } +serde_json.workspace = true session.workspace = true -settings.workspace = true +settings = { workspace = true, features = ["test-support"] } +title_bar = { workspace = true, features = ["test-support"] } +workspace = { workspace = true, features = ["test-support"] } zlog.workspace = true diff --git a/crates/settings_ui/src/components/dropdown.rs b/crates/settings_ui/src/components/dropdown.rs index ec9ecb4eafa64e1e061a0e44c2b6a1f4b23c8216..010a8ec0c040551a979ed5beb671c69c0914ecd3 100644 --- a/crates/settings_ui/src/components/dropdown.rs +++ b/crates/settings_ui/src/components/dropdown.rs @@ -17,7 +17,7 @@ where labels: &'static [&'static str], should_do_title_case: bool, tab_index: Option, - on_change: Rc, + on_change: Rc, } impl EnumVariantDropdown @@ -29,7 +29,7 @@ where current_value: T, variants: &'static [T], labels: &'static [&'static str], - on_change: impl Fn(T, &mut App) + 'static, + on_change: impl Fn(T, &mut ui::Window, &mut App) + 'static, ) -> Self { Self { id: id.into(), @@ -78,8 +78,8 @@ where value == current_value, IconPosition::End, None, - move |_, cx| { - on_change(value, cx); + move |window, cx| { + on_change(value, window, cx); }, ); } diff --git a/crates/settings_ui/src/components/font_picker.rs b/crates/settings_ui/src/components/font_picker.rs index 7a79009efdf38ba81f8b165c62a51e7d7fd5d1dc..564d98c6d2d9a768642c60157ff2c2b781c2ff0e 100644 --- a/crates/settings_ui/src/components/font_picker.rs +++ b/crates/settings_ui/src/components/font_picker.rs @@ -13,13 +13,13 @@ pub struct FontPickerDelegate { filtered_fonts: Vec, selected_index: usize, current_font: SharedString, - on_font_changed: Arc, + on_font_changed: Arc, } impl FontPickerDelegate { fn new( current_font: SharedString, - on_font_changed: impl Fn(SharedString, &mut App) + 'static, + on_font_changed: impl Fn(SharedString, &mut Window, &mut App) + 'static, cx: &mut Context, ) -> Self { let font_family_cache = FontFamilyCache::global(cx); @@ -132,10 +132,10 @@ impl PickerDelegate for FontPickerDelegate { Task::ready(()) } - fn confirm(&mut self, _secondary: bool, _window: &mut Window, cx: &mut Context) { + fn confirm(&mut self, _secondary: bool, window: &mut Window, cx: &mut Context) { if let Some(font_match) = self.filtered_fonts.get(self.selected_index) { let font = font_match.string.clone(); - (self.on_font_changed)(font.into(), cx); + (self.on_font_changed)(font.into(), window, cx); } } @@ -168,7 +168,7 @@ impl PickerDelegate for FontPickerDelegate { pub fn font_picker( current_font: SharedString, - on_font_changed: impl Fn(SharedString, &mut App) + 'static, + on_font_changed: impl Fn(SharedString, &mut Window, &mut App) + 'static, window: &mut Window, cx: &mut Context, ) -> FontPicker { diff --git a/crates/settings_ui/src/components/icon_theme_picker.rs b/crates/settings_ui/src/components/icon_theme_picker.rs index 33a648f81bcacdc961d77d7a5532c9807072dbd2..f369a8207dc334b83bf24258c2fc4805a251f6c0 100644 --- a/crates/settings_ui/src/components/icon_theme_picker.rs +++ b/crates/settings_ui/src/components/icon_theme_picker.rs @@ -13,13 +13,13 @@ pub struct IconThemePickerDelegate { filtered_themes: Vec, selected_index: usize, current_theme: SharedString, - on_theme_changed: Arc, + on_theme_changed: Arc, } impl IconThemePickerDelegate { fn new( current_theme: SharedString, - on_theme_changed: impl Fn(SharedString, &mut App) + 'static, + on_theme_changed: impl Fn(SharedString, &mut Window, &mut App) + 'static, cx: &mut Context, ) -> Self { let theme_registry = ThemeRegistry::global(cx); @@ -32,15 +32,15 @@ impl IconThemePickerDelegate { let selected_index = icon_themes .iter() - .position(|icon_themes| *icon_themes == current_theme) + .position(|icon_theme| *icon_theme == current_theme) .unwrap_or(0); let filtered_themes = icon_themes .iter() .enumerate() - .map(|(index, icon_themes)| StringMatch { + .map(|(index, theme)| StringMatch { candidate_id: index, - string: icon_themes.to_string(), + string: theme.to_string(), positions: Vec::new(), score: 0.0, }) @@ -67,13 +67,18 @@ impl PickerDelegate for IconThemePickerDelegate { self.selected_index } - fn set_selected_index(&mut self, ix: usize, _: &mut Window, cx: &mut Context) { - self.selected_index = ix.min(self.filtered_themes.len().saturating_sub(1)); + fn set_selected_index( + &mut self, + index: usize, + _window: &mut Window, + cx: &mut Context, + ) { + self.selected_index = index.min(self.filtered_themes.len().saturating_sub(1)); cx.notify(); } fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc { - "Search icon theme…".into() + "Search icon themes…".into() } fn update_matches( @@ -89,9 +94,9 @@ impl PickerDelegate for IconThemePickerDelegate { icon_themes .iter() .enumerate() - .map(|(index, icon_theme)| StringMatch { + .map(|(index, theme)| StringMatch { candidate_id: index, - string: icon_theme.to_string(), + string: theme.to_string(), positions: Vec::new(), score: 0.0, }) @@ -100,16 +105,16 @@ impl PickerDelegate for IconThemePickerDelegate { let _candidates: Vec = icon_themes .iter() .enumerate() - .map(|(id, icon_theme)| StringMatchCandidate::new(id, icon_theme.as_ref())) + .map(|(id, theme)| StringMatchCandidate::new(id, theme.as_ref())) .collect(); icon_themes .iter() .enumerate() - .filter(|(_, icon_theme)| icon_theme.to_lowercase().contains(&query.to_lowercase())) - .map(|(index, icon_theme)| StringMatch { + .filter(|(_, theme)| theme.to_lowercase().contains(&query.to_lowercase())) + .map(|(index, theme)| StringMatch { candidate_id: index, - string: icon_theme.to_string(), + string: theme.to_string(), positions: Vec::new(), score: 0.0, }) @@ -119,7 +124,7 @@ impl PickerDelegate for IconThemePickerDelegate { let selected_index = if query.is_empty() { icon_themes .iter() - .position(|icon_theme| *icon_theme == current_theme) + .position(|theme| *theme == current_theme) .unwrap_or(0) } else { matches @@ -138,12 +143,12 @@ impl PickerDelegate for IconThemePickerDelegate { fn confirm( &mut self, _secondary: bool, - _window: &mut Window, + window: &mut Window, cx: &mut Context, ) { if let Some(theme_match) = self.filtered_themes.get(self.selected_index) { let theme = theme_match.string.clone(); - (self.on_theme_changed)(theme.into(), cx); + (self.on_theme_changed)(theme.into(), window, cx); } } @@ -156,15 +161,15 @@ impl PickerDelegate for IconThemePickerDelegate { fn render_match( &self, - ix: usize, + index: usize, selected: bool, _window: &mut Window, _cx: &mut Context, ) -> Option { - let theme_match = self.filtered_themes.get(ix)?; + let theme_match = self.filtered_themes.get(index)?; Some( - ListItem::new(ix) + ListItem::new(index) .inset(true) .spacing(ListItemSpacing::Sparse) .toggle_state(selected) @@ -176,7 +181,7 @@ impl PickerDelegate for IconThemePickerDelegate { pub fn icon_theme_picker( current_theme: SharedString, - on_theme_changed: impl Fn(SharedString, &mut App) + 'static, + on_theme_changed: impl Fn(SharedString, &mut Window, &mut App) + 'static, window: &mut Window, cx: &mut Context, ) -> IconThemePicker { diff --git a/crates/settings_ui/src/components/input_field.rs b/crates/settings_ui/src/components/input_field.rs index 575da7f7ae13f8a304b23d57dd41607e7b7c512a..17afe89029e10f3df469426f390ead21c91e6b42 100644 --- a/crates/settings_ui/src/components/input_field.rs +++ b/crates/settings_ui/src/components/input_field.rs @@ -9,7 +9,7 @@ use ui::{ pub struct SettingsInputField { initial_text: Option, placeholder: Option<&'static str>, - confirm: Option, &mut App)>>, + confirm: Option, &mut Window, &mut App)>>, tab_index: Option, } @@ -34,7 +34,10 @@ impl SettingsInputField { self } - pub fn on_confirm(mut self, confirm: impl Fn(Option, &mut App) + 'static) -> Self { + pub fn on_confirm( + mut self, + confirm: impl Fn(Option, &mut Window, &mut App) + 'static, + ) -> Self { self.confirm = Some(Box::new(confirm)); self } @@ -83,13 +86,13 @@ impl RenderOnce for SettingsInputField { .child(editor) .when_some(self.confirm, |this, confirm| { this.on_action::({ - move |_, _, cx| { + move |_, window, 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); - confirm(new_value, cx); + confirm(new_value, window, cx); } }) }) diff --git a/crates/settings_ui/src/components/theme_picker.rs b/crates/settings_ui/src/components/theme_picker.rs index 2146ab314f94bb0c0535a462566e6673fc5601bc..a1f1339a7ad128c2bde690bf67e2b685628e0f39 100644 --- a/crates/settings_ui/src/components/theme_picker.rs +++ b/crates/settings_ui/src/components/theme_picker.rs @@ -13,13 +13,13 @@ pub struct ThemePickerDelegate { filtered_themes: Vec, selected_index: usize, current_theme: SharedString, - on_theme_changed: Arc, + on_theme_changed: Arc, } impl ThemePickerDelegate { fn new( current_theme: SharedString, - on_theme_changed: impl Fn(SharedString, &mut App) + 'static, + on_theme_changed: impl Fn(SharedString, &mut Window, &mut App) + 'static, cx: &mut Context, ) -> Self { let theme_registry = ThemeRegistry::global(cx); @@ -130,10 +130,10 @@ impl PickerDelegate for ThemePickerDelegate { Task::ready(()) } - fn confirm(&mut self, _secondary: bool, _window: &mut Window, cx: &mut Context) { + fn confirm(&mut self, _secondary: bool, window: &mut Window, cx: &mut Context) { if let Some(theme_match) = self.filtered_themes.get(self.selected_index) { let theme = theme_match.string.clone(); - (self.on_theme_changed)(theme.into(), cx); + (self.on_theme_changed)(theme.into(), window, cx); } } @@ -166,7 +166,7 @@ impl PickerDelegate for ThemePickerDelegate { pub fn theme_picker( current_theme: SharedString, - on_theme_changed: impl Fn(SharedString, &mut App) + 'static, + on_theme_changed: impl Fn(SharedString, &mut Window, &mut App) + 'static, window: &mut Window, cx: &mut Context, ) -> ThemePicker { diff --git a/crates/settings_ui/src/pages/edit_prediction_provider_setup.rs b/crates/settings_ui/src/pages/edit_prediction_provider_setup.rs index 6d14c245f06df3a03197a2714ceebaf0bb0999f3..b170c9a88f505b1873aec3acdbc8499e9dab836a 100644 --- a/crates/settings_ui/src/pages/edit_prediction_provider_setup.rs +++ b/crates/settings_ui/src/pages/edit_prediction_provider_setup.rs @@ -210,7 +210,7 @@ fn render_api_key_provider( SettingsInputField::new() .tab_index(0) .with_placeholder("xxxxxxxxxxxxxxxxxxxx") - .on_confirm(move |api_key, cx| { + .on_confirm(move |api_key, _window, cx| { write_key(api_key.filter(|key| !key.is_empty()), cx); }), ), diff --git a/crates/settings_ui/src/settings_ui.rs b/crates/settings_ui/src/settings_ui.rs index 9dc15ff9671b5e3bc777734c17ebfeeb1ffc88e9..1ad99252cad2c355182b1560b48e4e2bce7f71cb 100644 --- a/crates/settings_ui/src/settings_ui.rs +++ b/crates/settings_ui/src/settings_ui.rs @@ -2,16 +2,19 @@ mod components; mod page_data; mod pages; -use anyhow::Result; +use anyhow::{Context as _, Result}; use editor::{Editor, EditorEvent}; +use futures::{StreamExt, channel::mpsc}; use fuzzy::StringMatchCandidate; use gpui::{ - Action, App, ClipboardItem, DEFAULT_ADDITIONAL_WINDOW_SIZE, Div, Entity, FocusHandle, + Action, App, AsyncApp, ClipboardItem, DEFAULT_ADDITIONAL_WINDOW_SIZE, Div, Entity, FocusHandle, Focusable, Global, KeyContext, ListState, ReadGlobal as _, ScrollHandle, Stateful, - Subscription, Task, TitlebarOptions, UniformListScrollHandle, Window, WindowBounds, + Subscription, Task, TitlebarOptions, UniformListScrollHandle, WeakEntity, Window, WindowBounds, WindowHandle, WindowOptions, actions, div, list, point, prelude::*, px, uniform_list, }; -use project::{Project, WorktreeId}; + +use language::Buffer; +use project::{Project, ProjectPath, Worktree, WorktreeId}; use release_channel::ReleaseChannel; use schemars::JsonSchema; use serde::Deserialize; @@ -158,7 +161,7 @@ trait AnySettingField { current_file: &SettingsUiFile, file_set_in: &settings::SettingsFile, cx: &App, - ) -> Option>; + ) -> Option>; fn json_path(&self) -> Option<&'static str>; } @@ -188,7 +191,7 @@ impl AnySettingField for SettingFi current_file: &SettingsUiFile, file_set_in: &settings::SettingsFile, cx: &App, - ) -> Option> { + ) -> Option> { if file_set_in == &settings::SettingsFile::Default { return None; } @@ -207,7 +210,7 @@ impl AnySettingField for SettingFi } let current_file = current_file.clone(); - return Some(Box::new(move |cx| { + return Some(Box::new(move |window, cx| { let store = SettingsStore::global(cx); let default_value = (this.pick)(store.raw_default_settings()); let is_set_somewhere_other_than_default = store @@ -219,9 +222,15 @@ impl AnySettingField for SettingFi } else { None }; - update_settings_file(current_file.clone(), None, cx, move |settings, _| { - (this.write)(settings, value_to_set); - }) + update_settings_file( + current_file.clone(), + None, + window, + cx, + move |settings, _| { + (this.write)(settings, value_to_set); + }, + ) // todo(settings_ui): Don't log err .log_err(); })); @@ -375,6 +384,8 @@ struct SettingsFieldMetadata { pub fn init(cx: &mut App) { init_renderers(cx); + let queue = ProjectSettingsUpdateQueue::new(cx); + cx.set_global(queue); cx.observe_new(|workspace: &mut workspace::Workspace, _, _| { workspace @@ -586,7 +597,6 @@ pub fn open_settings_editor( } // We have to defer this to get the workspace off the stack. - let path = path.map(ToOwned::to_owned); cx.defer(move |cx| { let current_rem_size: f32 = theme::ThemeSettings::get_global(cx).ui_font_size(cx).into(); @@ -683,6 +693,8 @@ pub struct SettingsWindow { sub_page_stack: Vec, search_bar: Entity, search_task: Option>, + /// Cached settings file buffers to avoid repeated disk I/O on each settings change + project_setting_file_buffers: HashMap>, /// Index into navbar_entries navbar_entry: usize, navbar_entries: Vec, @@ -1120,8 +1132,8 @@ fn render_settings_item( .icon_size(IconSize::Small) .tooltip(Tooltip::text("Reset to Default")) .on_click({ - move |_, _, cx| { - reset_to_default(cx); + move |_, window, cx| { + reset_to_default(window, cx); } }), ) @@ -1567,6 +1579,7 @@ impl SettingsWindow { files: vec![], current_file: current_file, + project_setting_file_buffers: HashMap::default(), pages: vec![], sub_page_stack: vec![], navbar_entries: vec![], @@ -2075,14 +2088,9 @@ impl SettingsWindow { } if let Some(worktree_id) = settings_ui_file.worktree_id() { - let directory_name = all_projects(cx) + let directory_name = all_projects(self.original_window.as_ref(), cx) .find_map(|project| project.read(cx).worktree_for_id(worktree_id, cx)) - .and_then(|worktree| worktree.read(cx).root_dir()) - .and_then(|root_dir| { - root_dir - .file_name() - .map(|os_string| os_string.to_string_lossy().to_string()) - }); + .map(|worktree| worktree.read(cx).root_name()); let Some(directory_name) = directory_name else { log::error!( @@ -2092,7 +2100,8 @@ impl SettingsWindow { continue; }; - self.worktree_root_dirs.insert(worktree_id, directory_name); + self.worktree_root_dirs + .insert(worktree_id, directory_name.as_unix_str().to_string()); } let focus_handle = prev_files @@ -2108,7 +2117,7 @@ impl SettingsWindow { let mut missing_worktrees = Vec::new(); - for worktree in all_projects(cx) + for worktree in all_projects(self.original_window.as_ref(), cx) .flat_map(|project| project.read(cx).visible_worktrees(cx)) .filter(|tree| !self.worktree_root_dirs.contains_key(&tree.read(cx).id())) { @@ -3575,7 +3584,10 @@ impl Render for SettingsWindow { } } -fn all_projects(cx: &App) -> impl Iterator> { +fn all_projects( + window: Option<&WindowHandle>, + cx: &App, +) -> impl Iterator> { workspace::AppState::global(cx) .upgrade() .map(|app_state| { @@ -3585,6 +3597,9 @@ fn all_projects(cx: &App) -> impl Iterator> { .workspaces() .iter() .filter_map(|workspace| Some(workspace.read(cx).ok()?.project().clone())) + .chain( + window.and_then(|workspace| Some(workspace.read(cx).ok()?.project().clone())), + ) }) .into_iter() .flatten() @@ -3593,6 +3608,7 @@ fn all_projects(cx: &App) -> impl Iterator> { fn update_settings_file( file: SettingsUiFile, file_name: Option<&'static str>, + window: &mut Window, cx: &mut App, update: impl 'static + Send + FnOnce(&mut SettingsContent, &App), ) -> Result<()> { @@ -3601,41 +3617,11 @@ fn update_settings_file( match file { SettingsUiFile::Project((worktree_id, rel_path)) => { let rel_path = rel_path.join(paths::local_settings_file_relative_path()); - let Some((worktree, project)) = all_projects(cx).find_map(|project| { - project - .read(cx) - .worktree_for_id(worktree_id, cx) - .zip(Some(project)) - }) else { - anyhow::bail!("Could not find project with worktree id: {}", worktree_id); + let Some(settings_window) = window.root::().flatten() else { + anyhow::bail!("No settings window found"); }; - project.update(cx, |project, cx| { - let task = if project.contains_local_settings_file(worktree_id, &rel_path, cx) { - None - } else { - Some(worktree.update(cx, |worktree, cx| { - worktree.create_entry(rel_path.clone(), false, None, cx) - })) - }; - - cx.spawn(async move |project, cx| { - if let Some(task) = task - && task.await.is_err() - { - return; - }; - - project - .update(cx, |project, cx| { - project.update_local_settings_file(worktree_id, rel_path, cx, update); - }) - .ok(); - }) - .detach(); - }); - - return Ok(()); + update_project_setting_file(worktree_id, rel_path, update, settings_window, cx) } SettingsUiFile::User => { // todo(settings_ui) error? @@ -3646,6 +3632,153 @@ fn update_settings_file( } } +struct ProjectSettingsUpdateEntry { + worktree_id: WorktreeId, + rel_path: Arc, + settings_window: WeakEntity, + project: WeakEntity, + worktree: WeakEntity, + update: Box, +} + +struct ProjectSettingsUpdateQueue { + tx: mpsc::UnboundedSender, + _task: Task<()>, +} + +impl Global for ProjectSettingsUpdateQueue {} + +impl ProjectSettingsUpdateQueue { + fn new(cx: &mut App) -> Self { + let (tx, mut rx) = mpsc::unbounded(); + let task = cx.spawn(async move |mut cx| { + while let Some(entry) = rx.next().await { + if let Err(err) = Self::process_entry(entry, &mut cx).await { + log::error!("Failed to update project settings: {err:?}"); + } + } + }); + Self { tx, _task: task } + } + + fn enqueue(cx: &mut App, entry: ProjectSettingsUpdateEntry) { + cx.update_global::(|queue, _cx| { + if let Err(err) = queue.tx.unbounded_send(entry) { + log::error!("Failed to enqueue project settings update: {err}"); + } + }); + } + + async fn process_entry(entry: ProjectSettingsUpdateEntry, cx: &mut AsyncApp) -> Result<()> { + let ProjectSettingsUpdateEntry { + worktree_id, + rel_path, + settings_window, + project, + worktree, + update, + } = entry; + + let project_path = ProjectPath { + worktree_id, + path: rel_path.clone(), + }; + + let needs_creation = worktree.read_with(cx, |worktree, _| { + worktree.entry_for_path(&rel_path).is_none() + })?; + + if needs_creation { + worktree + .update(cx, |worktree, cx| { + worktree.create_entry(rel_path.clone(), false, None, cx) + })? + .await?; + } + + let buffer_store = project.read_with(cx, |project, _cx| project.buffer_store().clone())?; + + let cached_buffer = settings_window + .read_with(cx, |settings_window, _| { + settings_window + .project_setting_file_buffers + .get(&project_path) + .cloned() + }) + .unwrap_or_default(); + + let buffer = if let Some(cached_buffer) = cached_buffer { + let needs_reload = cached_buffer.read_with(cx, |buffer, _| buffer.has_conflict()); + if needs_reload { + cached_buffer + .update(cx, |buffer, cx| buffer.reload(cx)) + .await + .context("Failed to reload settings file")?; + } + cached_buffer + } else { + let buffer = buffer_store + .update(cx, |store, cx| store.open_buffer(project_path.clone(), cx)) + .await + .context("Failed to open settings file")?; + + let _ = settings_window.update(cx, |this, _cx| { + this.project_setting_file_buffers + .insert(project_path, buffer.clone()); + }); + + buffer + }; + + buffer.update(cx, |buffer, cx| { + let current_text = buffer.text(); + let new_text = cx + .global::() + .new_text_for_update(current_text, |settings| update(settings, cx)); + buffer.edit([(0..buffer.len(), new_text)], None, cx); + }); + + buffer_store + .update(cx, |store, cx| store.save_buffer(buffer, cx)) + .await + .context("Failed to save settings file")?; + + Ok(()) + } +} + +fn update_project_setting_file( + worktree_id: WorktreeId, + rel_path: Arc, + update: impl 'static + FnOnce(&mut SettingsContent, &App), + settings_window: Entity, + cx: &mut App, +) -> Result<()> { + let Some((worktree, project)) = + all_projects(settings_window.read(cx).original_window.as_ref(), cx).find_map(|project| { + project + .read(cx) + .worktree_for_id(worktree_id, cx) + .zip(Some(project)) + }) + else { + anyhow::bail!("Could not find project with worktree id: {}", worktree_id); + }; + + let entry = ProjectSettingsUpdateEntry { + worktree_id, + rel_path, + settings_window: settings_window.downgrade(), + project: project.downgrade(), + worktree: worktree.downgrade(), + update: Box::new(update), + }; + + ProjectSettingsUpdateQueue::enqueue(cx, entry); + + Ok(()) +} + fn render_text_field + Into + AsRef + Clone>( field: SettingField, file: SettingsUiFile, @@ -3667,10 +3800,16 @@ fn render_text_field + Into + AsRef + Clone>( |editor, placeholder| editor.with_placeholder(placeholder), ) .on_confirm({ - move |new_text, cx| { - update_settings_file(file.clone(), field.json_path, cx, move |settings, _cx| { - (field.write)(settings, new_text.map(Into::into)); - }) + move |new_text, window, cx| { + update_settings_file( + file.clone(), + field.json_path, + window, + cx, + move |settings, _cx| { + (field.write)(settings, new_text.map(Into::into)); + }, + ) .log_err(); // todo(settings_ui) don't log err } }) @@ -3695,11 +3834,11 @@ fn render_toggle_button + From + Copy>( Switch::new("toggle_button", toggle_state) .tab_index(0_isize) .on_click({ - move |state, _window, cx| { + move |state, window, cx| { telemetry::event!("Settings Change", setting = field.json_path, type = file.setting_type()); let state = *state == ui::ToggleState::Selected; - update_settings_file(file.clone(), field.json_path, cx, move |settings, _cx| { + update_settings_file(file.clone(), field.json_path, window, cx, move |settings, _cx| { (field.write)(settings, Some(state.into())); }) .log_err(); // todo(settings_ui) don't log err @@ -3726,11 +3865,17 @@ fn render_number_field( NumberField::new(id, value, window, cx) .tab_index(0_isize) .on_change({ - move |value, _window, cx| { + move |value, window, cx| { let value = *value; - update_settings_file(file.clone(), field.json_path, cx, move |settings, _cx| { - (field.write)(settings, Some(value)); - }) + update_settings_file( + file.clone(), + field.json_path, + window, + cx, + move |settings, _cx| { + (field.write)(settings, Some(value)); + }, + ) .log_err(); // todo(settings_ui) don't log err } }) @@ -3756,11 +3901,17 @@ fn render_editable_number_field( .mode(NumberFieldMode::Edit, cx) .tab_index(0_isize) .on_change({ - move |value, _window, cx| { + move |value, window, cx| { let value = *value; - update_settings_file(file.clone(), field.json_path, cx, move |settings, _cx| { - (field.write)(settings, Some(value)); - }) + update_settings_file( + file.clone(), + field.json_path, + window, + cx, + move |settings, _cx| { + (field.write)(settings, Some(value)); + }, + ) .log_err(); // todo(settings_ui) don't log err } }) @@ -3788,13 +3939,19 @@ where let current_value = current_value.copied().unwrap_or(variants()[0]); EnumVariantDropdown::new("dropdown", current_value, variants(), labels(), { - move |value, cx| { + move |value, window, cx| { if value == current_value { return; } - update_settings_file(file.clone(), field.json_path, cx, move |settings, _cx| { - (field.write)(settings, Some(value)); - }) + update_settings_file( + file.clone(), + field.json_path, + window, + cx, + move |settings, _cx| { + (field.write)(settings, Some(value)); + }, + ) .log_err(); // todo(settings_ui) don't log err } }) @@ -3839,10 +3996,11 @@ fn render_font_picker( Some(cx.new(move |cx| { font_picker( current_value, - move |font_name, cx| { + move |font_name, window, cx| { update_settings_file( file.clone(), field.json_path, + window, cx, move |settings, _cx| { (field.write)(settings, Some(font_name.to_string().into())); @@ -3888,10 +4046,11 @@ fn render_theme_picker( let current_value = current_value.clone(); theme_picker( current_value, - move |theme_name, cx| { + move |theme_name, window, cx| { update_settings_file( file.clone(), field.json_path, + window, cx, move |settings, _cx| { (field.write)( @@ -3940,10 +4099,11 @@ fn render_icon_theme_picker( let current_value = current_value.clone(); icon_theme_picker( current_value, - move |theme_name, cx| { + move |theme_name, window, cx| { update_settings_file( file.clone(), field.json_path, + window, cx, move |settings, _cx| { (field.write)( @@ -3977,6 +4137,51 @@ pub mod test { fn navbar_entry(&self) -> usize { self.navbar_entry } + + #[cfg(any(test, feature = "test-support"))] + pub fn test(window: &mut Window, cx: &mut Context) -> Self { + let search_bar = cx.new(|cx| Editor::single_line(window, cx)); + let dummy_page = SettingsPage { + title: "Test", + items: Box::new([]), + }; + Self { + title_bar: None, + original_window: None, + worktree_root_dirs: HashMap::default(), + files: Vec::default(), + current_file: SettingsUiFile::User, + project_setting_file_buffers: HashMap::default(), + pages: vec![dummy_page], + search_bar, + navbar_entry: 0, + navbar_entries: Vec::default(), + navbar_scroll_handle: UniformListScrollHandle::default(), + navbar_focus_subscriptions: Vec::default(), + filter_table: Vec::default(), + has_query: false, + content_handles: Vec::default(), + search_task: None, + sub_page_stack: Vec::default(), + focus_handle: cx.focus_handle(), + navbar_focus_handle: NonFocusableHandle::new( + NAVBAR_CONTAINER_TAB_INDEX, + false, + window, + cx, + ), + content_focus_handle: NonFocusableHandle::new( + CONTENT_CONTAINER_TAB_INDEX, + false, + window, + cx, + ), + files_focus_handle: cx.focus_handle(), + search_index: None, + list_state: ListState::new(0, gpui::ListAlignment::Top, px(0.0)), + shown_errors: HashSet::default(), + } + } } impl PartialEq for NavBarEntry { @@ -4069,6 +4274,7 @@ pub mod test { worktree_root_dirs: HashMap::default(), files: Vec::default(), current_file: crate::SettingsUiFile::User, + project_setting_file_buffers: HashMap::default(), pages, search_bar: cx.new(|cx| Editor::single_line(window, cx)), navbar_entry: selected_idx.expect("Must have a selected navbar entry"), @@ -4287,3 +4493,370 @@ pub mod test { " ); } + +#[cfg(test)] +mod project_settings_update_tests { + use super::*; + use fs::{FakeFs, Fs as _}; + use gpui::TestAppContext; + use project::Project; + use serde_json::json; + use std::sync::atomic::{AtomicUsize, Ordering}; + + struct TestSetup { + fs: Arc, + project: Entity, + worktree_id: WorktreeId, + worktree: WeakEntity, + rel_path: Arc, + project_path: ProjectPath, + } + + async fn init_test(cx: &mut TestAppContext, initial_settings: Option<&str>) -> TestSetup { + cx.update(|cx| { + let store = settings::SettingsStore::test(cx); + cx.set_global(store); + theme::init(theme::LoadThemes::JustBase, cx); + editor::init(cx); + menu::init(); + let queue = ProjectSettingsUpdateQueue::new(cx); + cx.set_global(queue); + }); + + let fs = FakeFs::new(cx.executor()); + let tree = if let Some(settings_content) = initial_settings { + json!({ + ".zed": { + "settings.json": settings_content + }, + "src": { "main.rs": "" } + }) + } else { + json!({ "src": { "main.rs": "" } }) + }; + fs.insert_tree("/project", tree).await; + + let project = Project::test(fs.clone(), ["/project".as_ref()], cx).await; + + let (worktree_id, worktree) = project.read_with(cx, |project, cx| { + let worktree = project.worktrees(cx).next().unwrap(); + (worktree.read(cx).id(), worktree.downgrade()) + }); + + let rel_path: Arc = RelPath::unix(".zed/settings.json") + .expect("valid path") + .into_arc(); + let project_path = ProjectPath { + worktree_id, + path: rel_path.clone(), + }; + + TestSetup { + fs, + project, + worktree_id, + worktree, + rel_path, + project_path, + } + } + + #[gpui::test] + async fn test_creates_settings_file_if_missing(cx: &mut TestAppContext) { + let setup = init_test(cx, None).await; + + let entry = ProjectSettingsUpdateEntry { + worktree_id: setup.worktree_id, + rel_path: setup.rel_path.clone(), + settings_window: WeakEntity::new_invalid(), + project: setup.project.downgrade(), + worktree: setup.worktree, + update: Box::new(|content, _cx| { + content.project.all_languages.defaults.tab_size = Some(NonZeroU32::new(4).unwrap()); + }), + }; + + cx.update(|cx| ProjectSettingsUpdateQueue::enqueue(cx, entry)); + cx.executor().run_until_parked(); + + let buffer_store = setup + .project + .read_with(cx, |project, _| project.buffer_store().clone()); + let buffer = buffer_store + .update(cx, |store, cx| store.open_buffer(setup.project_path, cx)) + .await + .expect("buffer should exist"); + + let text = buffer.read_with(cx, |buffer, _| buffer.text()); + assert!( + text.contains("\"tab_size\": 4"), + "Expected tab_size setting in: {}", + text + ); + } + + #[gpui::test] + async fn test_updates_existing_settings_file(cx: &mut TestAppContext) { + let setup = init_test(cx, Some(r#"{ "tab_size": 2 }"#)).await; + + let entry = ProjectSettingsUpdateEntry { + worktree_id: setup.worktree_id, + rel_path: setup.rel_path.clone(), + settings_window: WeakEntity::new_invalid(), + project: setup.project.downgrade(), + worktree: setup.worktree, + update: Box::new(|content, _cx| { + content.project.all_languages.defaults.tab_size = Some(NonZeroU32::new(8).unwrap()); + }), + }; + + cx.update(|cx| ProjectSettingsUpdateQueue::enqueue(cx, entry)); + cx.executor().run_until_parked(); + + let buffer_store = setup + .project + .read_with(cx, |project, _| project.buffer_store().clone()); + let buffer = buffer_store + .update(cx, |store, cx| store.open_buffer(setup.project_path, cx)) + .await + .expect("buffer should exist"); + + let text = buffer.read_with(cx, |buffer, _| buffer.text()); + assert!( + text.contains("\"tab_size\": 8"), + "Expected updated tab_size in: {}", + text + ); + } + + #[gpui::test] + async fn test_updates_are_serialized(cx: &mut TestAppContext) { + let setup = init_test(cx, Some("{}")).await; + + let update_order = Arc::new(std::sync::Mutex::new(Vec::new())); + + for i in 1..=3 { + let update_order = update_order.clone(); + let entry = ProjectSettingsUpdateEntry { + worktree_id: setup.worktree_id, + rel_path: setup.rel_path.clone(), + settings_window: WeakEntity::new_invalid(), + project: setup.project.downgrade(), + worktree: setup.worktree.clone(), + update: Box::new(move |content, _cx| { + update_order.lock().unwrap().push(i); + content.project.all_languages.defaults.tab_size = + Some(NonZeroU32::new(i).unwrap()); + }), + }; + cx.update(|cx| ProjectSettingsUpdateQueue::enqueue(cx, entry)); + } + + cx.executor().run_until_parked(); + + let order = update_order.lock().unwrap().clone(); + assert_eq!(order, vec![1, 2, 3], "Updates should be processed in order"); + + let buffer_store = setup + .project + .read_with(cx, |project, _| project.buffer_store().clone()); + let buffer = buffer_store + .update(cx, |store, cx| store.open_buffer(setup.project_path, cx)) + .await + .expect("buffer should exist"); + + let text = buffer.read_with(cx, |buffer, _| buffer.text()); + assert!( + text.contains("\"tab_size\": 3"), + "Final tab_size should be 3: {}", + text + ); + } + + #[gpui::test] + async fn test_queue_continues_after_failure(cx: &mut TestAppContext) { + let setup = init_test(cx, Some("{}")).await; + + let successful_updates = Arc::new(AtomicUsize::new(0)); + + { + let successful_updates = successful_updates.clone(); + let entry = ProjectSettingsUpdateEntry { + worktree_id: setup.worktree_id, + rel_path: setup.rel_path.clone(), + settings_window: WeakEntity::new_invalid(), + project: setup.project.downgrade(), + worktree: setup.worktree.clone(), + update: Box::new(move |content, _cx| { + successful_updates.fetch_add(1, Ordering::SeqCst); + content.project.all_languages.defaults.tab_size = + Some(NonZeroU32::new(2).unwrap()); + }), + }; + cx.update(|cx| ProjectSettingsUpdateQueue::enqueue(cx, entry)); + } + + { + let entry = ProjectSettingsUpdateEntry { + worktree_id: setup.worktree_id, + rel_path: setup.rel_path.clone(), + settings_window: WeakEntity::new_invalid(), + project: WeakEntity::new_invalid(), + worktree: setup.worktree.clone(), + update: Box::new(|content, _cx| { + content.project.all_languages.defaults.tab_size = + Some(NonZeroU32::new(99).unwrap()); + }), + }; + cx.update(|cx| ProjectSettingsUpdateQueue::enqueue(cx, entry)); + } + + { + let successful_updates = successful_updates.clone(); + let entry = ProjectSettingsUpdateEntry { + worktree_id: setup.worktree_id, + rel_path: setup.rel_path.clone(), + settings_window: WeakEntity::new_invalid(), + project: setup.project.downgrade(), + worktree: setup.worktree.clone(), + update: Box::new(move |content, _cx| { + successful_updates.fetch_add(1, Ordering::SeqCst); + content.project.all_languages.defaults.tab_size = + Some(NonZeroU32::new(4).unwrap()); + }), + }; + cx.update(|cx| ProjectSettingsUpdateQueue::enqueue(cx, entry)); + } + + cx.executor().run_until_parked(); + + assert_eq!( + successful_updates.load(Ordering::SeqCst), + 2, + "Two updates should have succeeded despite middle failure" + ); + + let buffer_store = setup + .project + .read_with(cx, |project, _| project.buffer_store().clone()); + let buffer = buffer_store + .update(cx, |store, cx| store.open_buffer(setup.project_path, cx)) + .await + .expect("buffer should exist"); + + let text = buffer.read_with(cx, |buffer, _| buffer.text()); + assert!( + text.contains("\"tab_size\": 4"), + "Final tab_size should be 4 (third update): {}", + text + ); + } + + #[gpui::test] + async fn test_handles_dropped_worktree(cx: &mut TestAppContext) { + let setup = init_test(cx, Some("{}")).await; + + let entry = ProjectSettingsUpdateEntry { + worktree_id: setup.worktree_id, + rel_path: setup.rel_path.clone(), + settings_window: WeakEntity::new_invalid(), + project: setup.project.downgrade(), + worktree: WeakEntity::new_invalid(), + update: Box::new(|content, _cx| { + content.project.all_languages.defaults.tab_size = + Some(NonZeroU32::new(99).unwrap()); + }), + }; + + cx.update(|cx| ProjectSettingsUpdateQueue::enqueue(cx, entry)); + cx.executor().run_until_parked(); + + let file_content = setup + .fs + .load("/project/.zed/settings.json".as_ref()) + .await + .unwrap(); + assert_eq!( + file_content, "{}", + "File should be unchanged when worktree is dropped" + ); + } + + #[gpui::test] + async fn test_reloads_conflicted_buffer(cx: &mut TestAppContext) { + let setup = init_test(cx, Some(r#"{ "tab_size": 2 }"#)).await; + + let buffer_store = setup + .project + .read_with(cx, |project, _| project.buffer_store().clone()); + let buffer = buffer_store + .update(cx, |store, cx| { + store.open_buffer(setup.project_path.clone(), cx) + }) + .await + .expect("buffer should exist"); + + buffer.update(cx, |buffer, cx| { + buffer.edit([(0..0, "// comment\n")], None, cx); + }); + + let has_unsaved_edits = buffer.read_with(cx, |buffer, _| buffer.has_unsaved_edits()); + assert!(has_unsaved_edits, "Buffer should have unsaved edits"); + + setup + .fs + .save( + "/project/.zed/settings.json".as_ref(), + &r#"{ "tab_size": 99 }"#.into(), + Default::default(), + ) + .await + .expect("save should succeed"); + + cx.executor().run_until_parked(); + + let has_conflict = buffer.read_with(cx, |buffer, _| buffer.has_conflict()); + assert!( + has_conflict, + "Buffer should have conflict after external modification" + ); + + let (settings_window, _) = cx.add_window_view(|window, cx| { + let mut sw = SettingsWindow::test(window, cx); + sw.project_setting_file_buffers + .insert(setup.project_path.clone(), buffer.clone()); + sw + }); + + let entry = ProjectSettingsUpdateEntry { + worktree_id: setup.worktree_id, + rel_path: setup.rel_path.clone(), + settings_window: settings_window.downgrade(), + project: setup.project.downgrade(), + worktree: setup.worktree.clone(), + update: Box::new(|content, _cx| { + content.project.all_languages.defaults.tab_size = Some(NonZeroU32::new(4).unwrap()); + }), + }; + + cx.update(|cx| ProjectSettingsUpdateQueue::enqueue(cx, entry)); + cx.executor().run_until_parked(); + + let text = buffer.read_with(cx, |buffer, _| buffer.text()); + assert!( + text.contains("\"tab_size\": 4"), + "Buffer should have the new tab_size after reload and update: {}", + text + ); + assert!( + !text.contains("// comment"), + "Buffer should not contain the unsaved edit after reload: {}", + text + ); + assert!( + !text.contains("99"), + "Buffer should not contain the external modification value: {}", + text + ); + } +}