From 5697636cd5424765f5f25ea6dbe771aabd22ccc5 Mon Sep 17 00:00:00 2001 From: Kirill Bulatov Date: Wed, 9 Sep 2026 12:27:32 +0000 Subject: [PATCH] Autosuggest Emmet extension (#63750) Closes https://github.com/zed-industries/zed/issues/32713 image https://github.com/user-attachments/assets/eea030ba-7a40-406d-bf43-3ac9c6af70f1 Release Notes: - Added Emmet extension autosuggestion --- Cargo.lock | 4 + crates/extensions_ui/Cargo.toml | 10 + crates/extensions_ui/src/extension_suggest.rs | 941 +++++++++++++++++- crates/extensions_ui/src/extensions_ui.rs | 14 +- crates/workspace/src/notifications.rs | 14 + 5 files changed, 919 insertions(+), 64 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1c47ee968c..587fd71eef 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6246,15 +6246,19 @@ dependencies = [ "fuzzy", "git", "gpui", + "http_client", "language", "log", + "markdown", "num-format", + "paths", "picker", "project", "release_channel", "schemars 1.0.4", "semver", "serde", + "serde_json", "settings", "strum 0.28.0", "telemetry", diff --git a/crates/extensions_ui/Cargo.toml b/crates/extensions_ui/Cargo.toml index c8581ec61c..f4a59df51b 100644 --- a/crates/extensions_ui/Cargo.toml +++ b/crates/extensions_ui/Cargo.toml @@ -27,6 +27,7 @@ git.workspace = true gpui.workspace = true language.workspace = true log.workspace = true +markdown.workspace = true num-format.workspace = true picker.workspace = true project.workspace = true @@ -45,7 +46,16 @@ workspace.workspace = true zed_actions.workspace = true [dev-dependencies] +db = { workspace = true, features = ["test-support"] } editor = { workspace = true, features = ["test-support"] } +fs = { workspace = true, features = ["test-support"] } +gpui = { workspace = true, features = ["test-support"] } +http_client = { workspace = true, features = ["test-support"] } +language = { workspace = true, features = ["test-support"] } +paths.workspace = true +project = { workspace = true, features = ["test-support"] } +serde_json.workspace = true +workspace = { workspace = true, features = ["test-support"] } [package.metadata.cargo-shear] ignored = ["component"] diff --git a/crates/extensions_ui/src/extension_suggest.rs b/crates/extensions_ui/src/extension_suggest.rs index 9400a197b2..6829d3e6d1 100644 --- a/crates/extensions_ui/src/extension_suggest.rs +++ b/crates/extensions_ui/src/extension_suggest.rs @@ -3,14 +3,19 @@ use std::sync::{Arc, OnceLock}; use db::kvp::KeyValueStore; use editor::Editor; -use extension_host::ExtensionStore; -use gpui::{AppContext as _, Context, Entity, SharedString, Window}; -use language::Buffer; +use extension_host::{ExtensionSettings, ExtensionStore}; +use gpui::{App, AppContext as _, Context, Entity, SharedString}; +use language::{Buffer, PLAIN_TEXT}; +use markdown::{Markdown, MarkdownElement}; +use project::lsp_store::LspStoreEvent; +use settings::Settings as _; use ui::prelude::*; use util::ResultExt; use util::rel_path::RelPath; -use workspace::notifications::simple_message_notification::MessageNotification; -use workspace::{Workspace, notifications::NotificationId}; +use workspace::notifications::{ + NotificationId, markdown_style, simple_message_notification::MessageNotification, +}; +use workspace::{AppState, Event as WorkspaceEvent, Workspace}; const SUGGESTIONS_BY_EXTENSION_ID: &[(&str, &[&str])] = &[ ("astro", &["astro"]), @@ -81,6 +86,89 @@ const SUGGESTIONS_BY_EXTENSION_ID: &[(&str, &[&str])] = &[ ("zig", &["zig"]), ]; +struct LanguageSuggestion { + extension_id: &'static str, + languages: &'static [&'static str], + title: &'static str, + description: &'static str, + docs_url: &'static str, + install_message: &'static str, +} + +const SUGGESTIONS_BY_LANGUAGE: &[LanguageSuggestion] = &[LanguageSuggestion { + extension_id: "emmet", + languages: &[ + "Angular", + "Blade", + "CSS", + "Django", + "ERB", + "Elixir", + "HEEx", + "HTML", + "HTML+ERB", + "JavaScript", + "Jinja2", + "LESS", + "Liquid", + "Nunjucks", + "PHP", + "SCSS", + "Statamic Antlers", + "TSX", + "Twig", + "Vue.js", + ], + title: "Emmet is available for this file", + description: "Emmet expands abbreviations such as `ul>li*3` into HTML and `m10` into CSS.", + docs_url: "https://zed.dev/docs/languages/emmet", + install_message: "Install Emmet", +}]; + +struct ExtensionSuggestionNotification; + +pub(crate) fn init(cx: &mut App) { + cx.observe_new(|workspace: &mut Workspace, window, cx| { + if window.is_none() { + return; + } + let lsp_store = workspace.project().read(cx).lsp_store(); + cx.subscribe(&lsp_store, |workspace, _, event, cx| { + if let LspStoreEvent::LanguageDetected { buffer, .. } = event { + suggest_for_buffer(workspace, buffer.clone(), cx); + } + }) + .detach(); + cx.subscribe_self(|workspace, event, cx| { + if let WorkspaceEvent::ItemAdded { item } = event + && let Some(editor) = item.downcast::() + && let Some(buffer) = editor.read(cx).buffer().read(cx).as_singleton() + { + suggest_for_buffer(workspace, buffer, cx); + } + }) + .detach(); + cx.subscribe( + &ExtensionStore::global(cx), + |workspace, extension_store, event, cx| { + if let extension_host::Event::ExtensionsUpdated = event { + let installed = extension_store + .read(cx) + .installed_extensions() + .keys() + .map(|extension_id| notification_id(extension_id)) + .collect::>(); + for installed in installed { + workspace.dismiss_notification(&installed, cx); + } + } + }, + ) + .detach(); + }) + .detach(); +} + fn suggested_extensions() -> &'static HashMap<&'static str, Arc> { static SUGGESTIONS_BY_PATH_SUFFIX: OnceLock>> = OnceLock::new(); SUGGESTIONS_BY_PATH_SUFFIX.get_or_init(|| { @@ -132,85 +220,198 @@ fn suggested_extension(path: &RelPath) -> Option { } fn language_extension_key(extension_id: &str) -> String { - format!("{}_extension_suggest", extension_id) + format!("{extension_id}_extension_suggest") } -pub(crate) fn suggest(buffer: Entity, window: &mut Window, cx: &mut Context) { - let Some(file) = buffer.read(cx).file().cloned() else { - return; - }; +fn suggestion_dismissed(extension_id: &str, cx: &App) -> bool { + KeyValueStore::global(cx) + .read_kvp(&language_extension_key(extension_id)) + .log_err() + != Some(None) +} - let Some(SuggestedExtension { - extension_id, - file_name_or_extension, - }) = suggested_extension(file.path()) - else { - return; - }; - - let key = language_extension_key(&extension_id); +fn dismiss_suggestion(extension_id: &str, cx: &mut App) { + let key = language_extension_key(extension_id); let kvp = KeyValueStore::global(cx); - let Ok(None) = kvp.read_kvp(&key) else { + db::write_and_log(cx, move || async move { + kvp.write_kvp(key, "dismissed".to_string()).await + }); + + let notification_id = notification_id(extension_id); + let workspaces = AppState::global(cx) + .workspace_store + .read(cx) + .workspaces() + .cloned() + .collect::>(); + for workspace in workspaces { + workspace + .update(cx, |workspace, cx| { + workspace.dismiss_notification(¬ification_id, cx); + }) + .ok(); + } +} + +fn notification_id(extension_id: &str) -> NotificationId { + NotificationId::composite::(SharedString::from(extension_id)) +} + +fn suggest_for_buffer( + workspace: &mut Workspace, + buffer: Entity, + cx: &mut Context, +) { + if !is_active_in_some_pane(workspace, &buffer, cx) { + return; + } + + let buffer = buffer.read(cx); + let Some(file) = buffer.file().cloned() else { return; }; + let language_name = buffer + .language() + .filter(|language| **language != *PLAIN_TEXT) + .map(|language| language.name()); - cx.on_next_frame(window, move |workspace, _, cx| { - let Some(editor) = workspace.active_item_as::(cx) else { - return; - }; - - if editor.read(cx).buffer().read(cx).as_singleton().as_ref() != Some(&buffer) { - return; + match language_name { + Some(language_name) => { + let Some(suggestion) = SUGGESTIONS_BY_LANGUAGE + .iter() + .find(|suggestion| suggestion.languages.contains(&language_name.as_ref())) + else { + return; + }; + show_suggestion(workspace, suggestion.extension_id, cx, |cx| { + let markdown = cx.new(|cx| { + Markdown::new( + SharedString::new_static(suggestion.description), + None, + None, + cx, + ) + }); + MessageNotification::new_from_builder(cx, move |window, cx| { + MarkdownElement::new(markdown.clone(), markdown_style(window, cx)) + .text_size(TextSize::Default.rems(cx)) + .into_any_element() + }) + .with_title(suggestion.title) + .more_info_message("Learn more") + .more_info_url(suggestion.docs_url) + .primary_message(suggestion.install_message) + .secondary_message("Don't show again") + }); } - - struct ExtensionSuggestionNotification; - - let notification_id = NotificationId::composite::( - SharedString::from(extension_id.clone()), - ); - - workspace.show_notification(notification_id, cx, |cx| { - cx.new(move |cx| { + None => { + let language_exists = workspace + .project() + .read(cx) + .languages() + .language_for_file(&file, Some(buffer.as_rope()), cx) + .is_some(); + if language_exists { + return; + } + let Some(SuggestedExtension { + extension_id, + file_name_or_extension, + }) = suggested_extension(file.path()) + else { + return; + }; + show_suggestion(workspace, &extension_id, cx, |cx| { MessageNotification::new( format!( - "Do you want to install the recommended '{}' extension for '{}' files?", - extension_id, file_name_or_extension + "Do you want to install the recommended '{extension_id}' extension for '{file_name_or_extension}' files?" ), cx, ) .primary_message("Yes, install extension") + .secondary_message("No, don't install it") + }); + } + } +} + +fn is_active_in_some_pane(workspace: &Workspace, buffer: &Entity, cx: &App) -> bool { + workspace.panes().iter().any(|pane| { + pane.read(cx) + .active_item() + .and_then(|item| item.downcast::()) + .is_some_and(|editor| { + editor.read(cx).buffer().read(cx).as_singleton().as_ref() == Some(buffer) + }) + }) +} + +fn show_suggestion( + workspace: &mut Workspace, + extension_id: &str, + cx: &mut Context, + build_notification: impl FnOnce(&mut Context) -> MessageNotification, +) { + let notification_id = notification_id(extension_id); + if workspace.has_notification(¬ification_id) { + return; + } + + let extension_store = ExtensionStore::global(cx); + let extension_store = extension_store.read(cx); + if extension_store + .installed_extensions() + .contains_key(extension_id) + || extension_store + .outstanding_operations() + .contains_key(extension_id) + || ExtensionSettings::get_global(cx) + .auto_install_extensions + .get(extension_id) + == Some(&true) + || suggestion_dismissed(extension_id, cx) + { + return; + } + + let extension_id = Arc::::from(extension_id); + workspace.show_notification(notification_id, cx, |cx| { + cx.new(|cx| { + build_notification(cx) .primary_icon(IconName::Check) .primary_icon_color(Color::Success) .primary_on_click({ let extension_id = extension_id.clone(); move |_window, cx| { - let extension_id = extension_id.clone(); - let extension_store = ExtensionStore::global(cx); - extension_store.update(cx, move |store, cx| { - store.install_latest_extension(extension_id, cx); + ExtensionStore::global(cx).update(cx, |store, cx| { + store.install_latest_extension(extension_id.clone(), cx); }); } }) - .secondary_message("No, don't install it") .secondary_icon(IconName::Close) .secondary_icon_color(Color::Error) - .secondary_on_click(move |_window, cx| { - let key = language_extension_key(&extension_id); - let kvp = KeyValueStore::global(cx); - cx.background_spawn(async move { - kvp.write_kvp(key, "dismissed".to_string()).await.log_err() - }) - .detach(); - }) - }) - }); - }) + .secondary_on_click(move |_window, cx| dismiss_suggestion(&extension_id, cx)) + }) + }); } #[cfg(test)] mod tests { use super::*; + use extension::ExtensionHostProxy; + use extension_host::RELOAD_DEBOUNCE_DURATION; + use fs::RemoveOptions; + use gpui::{AnyView, TestAppContext, VisualTestContext}; + use http_client::HttpClient as _; + use language::{Language, LanguageConfig, LanguageMatcher}; + use project::{Project, WorktreeId}; + use serde_json::json; + use settings::SettingsStore; + use util::path; use util::rel_path::rel_path; + use workspace::{AppState, SplitDirection}; + + const EMMET_EXTENSION_ID: &str = "emmet"; #[test] pub fn test_suggested_extension() { @@ -250,4 +451,636 @@ mod tests { }) ); } + + #[gpui::test] + async fn test_language_suggestion_for_supported_language(cx: &mut TestAppContext) { + let app_state = init_test(cx); + let (workspace, cx) = open_test_workspace(&app_state, cx).await; + + open_file(&workspace, "index.html", cx).await; + + assert_eq!( + notification_ids(&workspace, cx), + vec![notification_id(EMMET_EXTENSION_ID)] + ); + } + + #[gpui::test] + async fn test_language_suggestion_when_language_is_detected_after_editor( + cx: &mut TestAppContext, + ) { + let app_state = init_test(cx); + let (workspace, cx) = open_test_workspace(&app_state, cx).await; + + open_file(&workspace, "main.rs", cx).await; + assert_eq!(notification_ids(&workspace, cx), Vec::new()); + + let (project, buffer) = workspace.read_with(cx, |workspace, cx| { + let editor = workspace.active_item_as::(cx).unwrap(); + let buffer = editor.read(cx).buffer().read(cx).as_singleton().unwrap(); + (workspace.project().clone(), buffer) + }); + let html = project + .read_with(cx, |project, _| { + project.languages().language_for_name("HTML") + }) + .await + .unwrap(); + project.update(cx, |project, cx| { + project.set_language_for_buffer(&buffer, html, cx); + }); + cx.run_until_parked(); + + assert_eq!( + notification_ids(&workspace, cx), + vec![notification_id(EMMET_EXTENSION_ID)] + ); + } + + #[gpui::test] + async fn test_no_suggestion_for_buffer_without_active_editor(cx: &mut TestAppContext) { + let app_state = init_test(cx); + let (workspace, cx) = open_test_workspace(&app_state, cx).await; + + open_file(&workspace, "main.rs", cx).await; + let worktree_id = worktree_id(&workspace, cx); + let project = workspace.read_with(cx, |workspace, _| workspace.project().clone()); + project + .update(cx, |project, cx| { + project.open_buffer((worktree_id, rel_path("index.html")), cx) + }) + .await + .unwrap(); + cx.run_until_parked(); + + assert_eq!(notification_ids(&workspace, cx), Vec::new()); + } + + #[gpui::test] + async fn test_no_language_suggestion_for_unsupported_language(cx: &mut TestAppContext) { + let app_state = init_test(cx); + let (workspace, cx) = open_test_workspace(&app_state, cx).await; + + open_file(&workspace, "main.rs", cx).await; + + assert_eq!(notification_ids(&workspace, cx), Vec::new()); + } + + #[gpui::test] + async fn test_no_suggestion_after_dismissal(cx: &mut TestAppContext) { + let app_state = init_test(cx); + cx.update(|cx| dismiss_suggestion(EMMET_EXTENSION_ID, cx)); + cx.run_until_parked(); + let (workspace, cx) = open_test_workspace(&app_state, cx).await; + + open_file(&workspace, "index.html", cx).await; + + assert_eq!(notification_ids(&workspace, cx), Vec::new()); + } + + #[gpui::test] + async fn test_dismissal_applies_to_already_open_workspace(cx: &mut TestAppContext) { + let app_state = init_test(cx); + let (workspace, cx) = open_test_workspace(&app_state, cx).await; + + open_file(&workspace, "index.html", cx).await; + assert_eq!( + notification_ids(&workspace, cx), + vec![notification_id(EMMET_EXTENSION_ID)] + ); + + cx.update(|_, cx| dismiss_suggestion(EMMET_EXTENSION_ID, cx)); + cx.run_until_parked(); + workspace.update(cx, |workspace, cx| { + workspace.dismiss_notification(¬ification_id(EMMET_EXTENSION_ID), cx) + }); + + open_file(&workspace, "other.html", cx).await; + + assert_eq!(notification_ids(&workspace, cx), Vec::new()); + } + + #[gpui::test] + async fn test_no_suggestion_when_installed(cx: &mut TestAppContext) { + let app_state = init_test(cx); + install_emmet_on_disk(&app_state, cx).await; + let (workspace, cx) = open_test_workspace(&app_state, cx).await; + + open_file(&workspace, "index.html", cx).await; + + assert_eq!(notification_ids(&workspace, cx), Vec::new()); + } + + #[gpui::test] + async fn test_dont_show_again_dismisses_card_in_all_workspaces(cx: &mut TestAppContext) { + let app_state = init_test(cx); + let (workspace, cx) = open_test_workspace(&app_state, cx).await; + let (other_workspace, cx) = open_test_workspace(&app_state, cx).await; + open_file(&workspace, "index.html", cx).await; + open_file(&other_workspace, "index.html", cx).await; + assert_eq!( + notification_ids(&workspace, cx), + vec![notification_id(EMMET_EXTENSION_ID)] + ); + assert_eq!( + notification_ids(&other_workspace, cx), + vec![notification_id(EMMET_EXTENSION_ID)] + ); + + cx.update(|_, cx| dismiss_suggestion(EMMET_EXTENSION_ID, cx)); + cx.run_until_parked(); + + assert_eq!(notification_ids(&workspace, cx), Vec::new()); + assert_eq!(notification_ids(&other_workspace, cx), Vec::new()); + } + + #[gpui::test] + async fn test_visible_suggestion_survives_unrelated_extension_install(cx: &mut TestAppContext) { + let app_state = init_test(cx); + let (workspace, cx) = open_test_workspace(&app_state, cx).await; + + open_file(&workspace, "index.html", cx).await; + let shown = notification_views(&workspace, cx); + assert_eq!(shown.len(), 1); + + install_extension_on_disk("gleam", &app_state, cx).await; + + let still_shown = notification_views(&workspace, cx); + assert_eq!(still_shown.len(), 1); + assert_eq!(still_shown[0].entity_id(), shown[0].entity_id()); + } + + #[gpui::test] + async fn test_visible_suggestion_is_dismissed_when_extension_gets_installed( + cx: &mut TestAppContext, + ) { + let app_state = init_test(cx); + let (workspace, cx) = open_test_workspace(&app_state, cx).await; + + open_file(&workspace, "index.html", cx).await; + assert_eq!( + notification_ids(&workspace, cx), + vec![notification_id(EMMET_EXTENSION_ID)] + ); + + install_emmet_on_disk(&app_state, cx).await; + + assert_eq!(notification_ids(&workspace, cx), Vec::new()); + } + + #[gpui::test] + async fn test_suggestion_returns_after_uninstall(cx: &mut TestAppContext) { + let app_state = init_test(cx); + install_emmet_on_disk(&app_state, cx).await; + let (workspace, cx) = open_test_workspace(&app_state, cx).await; + + open_file(&workspace, "index.html", cx).await; + assert_eq!(notification_ids(&workspace, cx), Vec::new()); + + app_state + .fs + .remove_dir( + &paths::extensions_dir() + .join("installed") + .join(EMMET_EXTENSION_ID), + RemoveOptions { + recursive: true, + ignore_if_not_exists: false, + }, + ) + .await + .unwrap(); + cx.executor().advance_clock(RELOAD_DEBOUNCE_DURATION); + cx.run_until_parked(); + cx.update(|_, cx| { + assert_eq!( + ExtensionStore::global(cx) + .read(cx) + .installed_extensions() + .keys() + .collect::>(), + Vec::<&Arc>::new() + ); + }); + open_file(&workspace, "other.html", cx).await; + + assert_eq!( + notification_ids(&workspace, cx), + vec![notification_id(EMMET_EXTENSION_ID)] + ); + } + + #[gpui::test] + async fn test_no_suggestion_while_install_is_pending(cx: &mut TestAppContext) { + let app_state = init_test(cx); + app_state + .client + .http_client() + .as_fake() + .replace_handler(|_, _| std::future::pending()); + cx.update(|cx| { + ExtensionStore::global(cx).update(cx, |store, cx| { + store.install_latest_extension(Arc::from(EMMET_EXTENSION_ID), cx); + }); + }); + cx.run_until_parked(); + cx.update(|cx| { + assert_eq!( + ExtensionStore::global(cx) + .read(cx) + .outstanding_operations() + .keys() + .collect::>(), + vec![&Arc::from(EMMET_EXTENSION_ID)] + ); + }); + let (workspace, cx) = open_test_workspace(&app_state, cx).await; + + open_file(&workspace, "index.html", cx).await; + + assert_eq!(notification_ids(&workspace, cx), Vec::new()); + } + + #[gpui::test] + async fn test_suggestion_returns_after_failed_install(cx: &mut TestAppContext) { + let app_state = init_test(cx); + cx.update(|cx| { + ExtensionStore::global(cx).update(cx, |store, cx| { + store.install_latest_extension(Arc::from(EMMET_EXTENSION_ID), cx); + }); + }); + cx.run_until_parked(); + cx.update(|cx| { + let store = ExtensionStore::global(cx); + let store = store.read(cx); + assert_eq!(store.outstanding_operations().len(), 0); + assert_eq!(store.installed_extensions().len(), 0); + }); + let (workspace, cx) = open_test_workspace(&app_state, cx).await; + + open_file(&workspace, "index.html", cx).await; + + assert_eq!( + notification_ids(&workspace, cx), + vec![notification_id(EMMET_EXTENSION_ID)] + ); + } + + #[gpui::test] + async fn test_suggestion_returns_after_uninstall_through_store(cx: &mut TestAppContext) { + let app_state = init_test(cx); + install_emmet_on_disk(&app_state, cx).await; + let (workspace, cx) = open_test_workspace(&app_state, cx).await; + + open_file(&workspace, "index.html", cx).await; + assert_eq!(notification_ids(&workspace, cx), Vec::new()); + + let uninstall = cx.update(|_, cx| { + ExtensionStore::global(cx).update(cx, |store, cx| { + store.uninstall_extension(Arc::from(EMMET_EXTENSION_ID), cx) + }) + }); + cx.executor().advance_clock(RELOAD_DEBOUNCE_DURATION * 4); + uninstall.await.unwrap(); + cx.run_until_parked(); + assert_eq!(notification_ids(&workspace, cx), Vec::new()); + + open_file(&workspace, "other.html", cx).await; + + assert_eq!( + notification_ids(&workspace, cx), + vec![notification_id(EMMET_EXTENSION_ID)] + ); + } + + #[gpui::test] + async fn test_no_suggestion_when_auto_install_is_enabled(cx: &mut TestAppContext) { + assert_eq!( + suggestions_with_auto_install_setting(true, cx).await, + Vec::new() + ); + } + + #[gpui::test] + async fn test_suggestion_when_auto_install_is_disabled(cx: &mut TestAppContext) { + assert_eq!( + suggestions_with_auto_install_setting(false, cx).await, + vec![notification_id(EMMET_EXTENSION_ID)] + ); + } + + #[gpui::test] + async fn test_visible_suggestion_is_not_rebuilt_by_next_candidate(cx: &mut TestAppContext) { + let app_state = init_test(cx); + let (workspace, cx) = open_test_workspace(&app_state, cx).await; + + open_file(&workspace, "index.html", cx).await; + let shown = notification_views(&workspace, cx); + assert_eq!(shown.len(), 1); + + open_file(&workspace, "other.html", cx).await; + + let still_shown = notification_views(&workspace, cx); + assert_eq!(still_shown.len(), 1); + assert_eq!(still_shown[0].entity_id(), shown[0].entity_id()); + } + + #[gpui::test] + async fn test_closed_suggestion_returns_for_next_candidate(cx: &mut TestAppContext) { + let app_state = init_test(cx); + let (workspace, cx) = open_test_workspace(&app_state, cx).await; + + open_file(&workspace, "index.html", cx).await; + let notification = notification_views(&workspace, cx) + .pop() + .unwrap() + .downcast::() + .ok() + .unwrap(); + notification.update(cx, |notification, cx| notification.dismiss(cx)); + cx.run_until_parked(); + assert_eq!(notification_ids(&workspace, cx), Vec::new()); + assert!(!cx.update(|_, cx| suggestion_dismissed(EMMET_EXTENSION_ID, cx))); + + open_file(&workspace, "other.html", cx).await; + + assert_eq!( + notification_ids(&workspace, cx), + vec![notification_id(EMMET_EXTENSION_ID)] + ); + } + + #[gpui::test] + async fn test_suggestion_for_item_added_to_inactive_pane(cx: &mut TestAppContext) { + let app_state = init_test(cx); + let (workspace, cx) = open_test_workspace(&app_state, cx).await; + + open_file(&workspace, "main.rs", cx).await; + let first_pane = workspace.read_with(cx, |workspace, _| workspace.active_pane().clone()); + let second_pane = workspace.update_in(cx, |workspace, window, cx| { + workspace.split_pane(first_pane.clone(), SplitDirection::Right, window, cx) + }); + cx.run_until_parked(); + let active_pane = workspace.read_with(cx, |workspace, _| workspace.active_pane().clone()); + let inactive_pane = if active_pane == first_pane { + second_pane + } else { + first_pane + }; + + let worktree_id = worktree_id(&workspace, cx); + let project = workspace.read_with(cx, |workspace, _| workspace.project().clone()); + let buffer = project + .update(cx, |project, cx| { + project.open_buffer((worktree_id, rel_path("index.html")), cx) + }) + .await + .unwrap(); + inactive_pane.update_in(cx, |pane, window, cx| { + let editor = cx.new(|cx| Editor::for_buffer(buffer, Some(project), window, cx)); + pane.add_item(Box::new(editor), false, false, None, window, cx); + }); + cx.run_until_parked(); + + assert_eq!( + workspace.read_with(cx, |workspace, _| workspace.active_pane().clone()), + active_pane + ); + assert_eq!( + notification_ids(&workspace, cx), + vec![notification_id(EMMET_EXTENSION_ID)] + ); + } + + #[gpui::test] + async fn test_file_suggestion_for_unknown_language(cx: &mut TestAppContext) { + let app_state = init_test(cx); + let (workspace, cx) = open_test_workspace(&app_state, cx).await; + + open_file(&workspace, "main.gleam", cx).await; + + assert_eq!( + notification_ids(&workspace, cx), + vec![notification_id("gleam")] + ); + } + + #[gpui::test] + async fn test_file_suggestion_for_untitled_buffer_saved_as_unknown_language( + cx: &mut TestAppContext, + ) { + let app_state = init_test(cx); + let (workspace, cx) = open_test_workspace(&app_state, cx).await; + let project = workspace.read_with(cx, |workspace, _| workspace.project().clone()); + + let buffer = project + .update(cx, |project, cx| project.create_buffer(None, false, cx)) + .await + .unwrap(); + workspace.update_in(cx, |workspace, window, cx| { + let editor = + cx.new(|cx| Editor::for_buffer(buffer.clone(), Some(project.clone()), window, cx)); + workspace.add_item_to_active_pane(Box::new(editor), None, true, window, cx); + }); + cx.run_until_parked(); + assert_eq!(notification_ids(&workspace, cx), Vec::new()); + + let worktree_id = worktree_id(&workspace, cx); + project + .update(cx, |project, cx| { + project.save_buffer_as(buffer, (worktree_id, rel_path("new.gleam")).into(), cx) + }) + .await + .unwrap(); + cx.run_until_parked(); + + assert_eq!( + notification_ids(&workspace, cx), + vec![notification_id("gleam")] + ); + } + + #[gpui::test] + async fn test_no_file_suggestion_while_language_is_loading(cx: &mut TestAppContext) { + let app_state = init_test(cx); + let (workspace, cx) = open_test_workspace(&app_state, cx).await; + let gleam = test_language_config("Gleam", "gleam"); + workspace.read_with(cx, |workspace, cx| { + workspace.project().read(cx).languages().register_language( + gleam.name, + None, + gleam.matcher, + false, + None, + Arc::new(|| Box::pin(std::future::pending())), + ); + }); + + open_file(&workspace, "main.gleam", cx).await; + + assert_eq!(notification_ids(&workspace, cx), Vec::new()); + } + + fn init_test(cx: &mut TestAppContext) -> Arc { + cx.update(|cx| { + let app_state = AppState::test(cx); + AppState::set_global(app_state.clone(), cx); + cx.update_global::(|store, cx| { + store.update_user_settings(cx, |content| { + content + .extension + .auto_install_extensions + .insert(Arc::from("html"), false); + }); + }); + cx.set_global(db::AppDatabase::test_new()); + release_channel::init(semver::Version::new(0, 0, 0), cx); + extension::init(cx); + extension_host::init( + Arc::new(ExtensionHostProxy::new()), + app_state.fs.clone(), + app_state.client.clone(), + app_state.node_runtime.clone(), + cx, + ); + editor::init(cx); + crate::init(cx); + app_state + }) + } + + async fn install_emmet_on_disk(app_state: &Arc, cx: &mut TestAppContext) { + install_extension_on_disk(EMMET_EXTENSION_ID, app_state, cx).await; + } + + async fn install_extension_on_disk( + extension_id: &str, + app_state: &Arc, + cx: &mut TestAppContext, + ) { + app_state + .fs + .as_fake() + .insert_tree( + paths::extensions_dir().join("installed").join(extension_id), + json!({ + "extension.toml": format!( + "id = \"{extension_id}\"\nname = \"{extension_id}\"\nversion = \"0.0.1\"\nschema_version = 1\n" + ), + }), + ) + .await; + cx.executor().advance_clock(RELOAD_DEBOUNCE_DURATION); + cx.run_until_parked(); + cx.update(|cx| { + assert!( + ExtensionStore::global(cx) + .read(cx) + .installed_extensions() + .contains_key(extension_id) + ); + }); + } + + async fn open_test_workspace<'a>( + app_state: &Arc, + cx: &'a mut TestAppContext, + ) -> (Entity, &'a mut VisualTestContext) { + let fs = app_state.fs.as_fake(); + fs.insert_tree( + path!("/root"), + json!({ + "index.html": "
", + "other.html": "", + "main.rs": "fn main() {}", + "main.gleam": "pub fn main() {}", + }), + ) + .await; + let project = Project::test(fs, [path!("/root").as_ref()], cx).await; + project.read_with(cx, |project, _| { + project + .languages() + .add(Arc::new(test_language("HTML", "html"))); + project + .languages() + .add(Arc::new(test_language("Rust", "rs"))); + }); + cx.add_window_view(|window, cx| { + Workspace::new(None, project, app_state.clone(), window, cx) + }) + } + + async fn open_file(workspace: &Entity, file_name: &str, cx: &mut VisualTestContext) { + let worktree_id = worktree_id(workspace, cx); + workspace + .update_in(cx, |workspace, window, cx| { + workspace.open_path((worktree_id, rel_path(file_name)), None, true, window, cx) + }) + .await + .unwrap(); + cx.run_until_parked(); + } + + fn worktree_id(workspace: &Entity, cx: &VisualTestContext) -> WorktreeId { + workspace.read_with(cx, |workspace, cx| { + workspace + .project() + .read(cx) + .worktrees(cx) + .next() + .unwrap() + .read(cx) + .id() + }) + } + + async fn suggestions_with_auto_install_setting( + auto_install: bool, + cx: &mut TestAppContext, + ) -> Vec { + let app_state = init_test(cx); + cx.update(|cx| { + cx.update_global::(|store, cx| { + store.update_user_settings(cx, |content| { + content + .extension + .auto_install_extensions + .insert(Arc::from(EMMET_EXTENSION_ID), auto_install); + }); + }); + }); + let (workspace, cx) = open_test_workspace(&app_state, cx).await; + + open_file(&workspace, "index.html", cx).await; + + notification_ids(&workspace, cx) + } + + fn notification_ids( + workspace: &Entity, + cx: &VisualTestContext, + ) -> Vec { + workspace.read_with(cx, |workspace, _| workspace.notification_ids()) + } + + fn notification_views(workspace: &Entity, cx: &VisualTestContext) -> Vec { + workspace.read_with(cx, |workspace, _| workspace.notification_views()) + } + + fn test_language(name: &'static str, path_suffix: &str) -> Language { + Language::new(test_language_config(name, path_suffix), None) + } + + fn test_language_config(name: &'static str, path_suffix: &str) -> LanguageConfig { + LanguageConfig { + name: name.into(), + matcher: LanguageMatcher { + path_suffixes: vec![path_suffix.to_string()], + ..LanguageMatcher::default() + } + .into(), + ..LanguageConfig::default() + } + } } diff --git a/crates/extensions_ui/src/extensions_ui.rs b/crates/extensions_ui/src/extensions_ui.rs index dbae10cbdd..f7b5744e20 100644 --- a/crates/extensions_ui/src/extensions_ui.rs +++ b/crates/extensions_ui/src/extensions_ui.rs @@ -109,11 +109,12 @@ pub fn init(cx: &mut App) { update_rebuild_dev_extension_visibility(&store, cx); }) .detach(); + extension_suggest::init(cx); - cx.observe_new(move |workspace: &mut Workspace, window, cx| { - let Some(window) = window else { + cx.observe_new(move |workspace: &mut Workspace, window, _cx| { + if window.is_none() { return; - }; + } workspace .register_action( move |workspace, action: &zed_actions::Extensions, window, cx| { @@ -281,13 +282,6 @@ pub fn init(cx: &mut App) { } } }); - - cx.subscribe_in(workspace.project(), window, |_, _, event, window, cx| { - if let project::Event::LanguageNotFound(buffer) = event { - extension_suggest::suggest(buffer.clone(), window, cx); - } - }) - .detach(); }) .detach(); } diff --git a/crates/workspace/src/notifications.rs b/crates/workspace/src/notifications.rs index 860ac2ad9f..697f4f3ed5 100644 --- a/crates/workspace/src/notifications.rs +++ b/crates/workspace/src/notifications.rs @@ -71,6 +71,12 @@ pub trait Notification: pub struct SuppressEvent; impl Workspace { + pub fn has_notification(&self, id: &NotificationId) -> bool { + self.notifications + .iter() + .any(|(existing_id, _)| existing_id == id) + } + #[cfg(any(test, feature = "test-support"))] pub fn notification_ids(&self) -> Vec { self.notifications @@ -80,6 +86,14 @@ impl Workspace { .collect() } + #[cfg(any(test, feature = "test-support"))] + pub fn notification_views(&self) -> Vec { + self.notifications + .iter() + .map(|(_, view)| view.clone()) + .collect() + } + pub fn show_notification( &mut self, id: NotificationId,