Autosuggest Emmet extension (#63750)
Closes https://github.com/zed-industries/zed/issues/32713 <img width="507" height="160" alt="image" src="https://github.com/user-attachments/assets/356973ce-51b8-43be-9b4c-700d0d5dd009" /> https://github.com/user-attachments/assets/eea030ba-7a40-406d-bf43-3ac9c6af70f1 Release Notes: - Added Emmet extension autosuggestion
This commit is contained in:
1 parent
5e25888c38
commit
5697636cd5
5 files changed
+919
-64
No files matched your search
Generated
+4
@@ -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",
|
||||
|
||||
@@ -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"]
|
||||
@@ -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::<Editor>()
|
||||
&& 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::<Vec<_>>();
|
||||
for installed in installed {
|
||||
workspace.dismiss_notification(&installed, cx);
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
.detach();
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
fn suggested_extensions() -> &'static HashMap<&'static str, Arc<str>> {
|
||||
static SUGGESTIONS_BY_PATH_SUFFIX: OnceLock<HashMap<&str, Arc<str>>> = OnceLock::new();
|
||||
SUGGESTIONS_BY_PATH_SUFFIX.get_or_init(|| {
|
||||
@@ -132,85 +220,198 @@ fn suggested_extension(path: &RelPath) -> Option<SuggestedExtension> {
|
||||
}
|
||||
|
||||
fn language_extension_key(extension_id: &str) -> String {
|
||||
format!("{}_extension_suggest", extension_id)
|
||||
format!("{extension_id}_extension_suggest")
|
||||
}
|
||||
|
||||
pub(crate) fn suggest(buffer: Entity<Buffer>, window: &mut Window, cx: &mut Context<Workspace>) {
|
||||
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::<Vec<_>>();
|
||||
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::<ExtensionSuggestionNotification>(SharedString::from(extension_id))
|
||||
}
|
||||
|
||||
fn suggest_for_buffer(
|
||||
workspace: &mut Workspace,
|
||||
buffer: Entity<Buffer>,
|
||||
cx: &mut Context<Workspace>,
|
||||
) {
|
||||
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::<Editor>(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::<ExtensionSuggestionNotification>(
|
||||
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<Buffer>, cx: &App) -> bool {
|
||||
workspace.panes().iter().any(|pane| {
|
||||
pane.read(cx)
|
||||
.active_item()
|
||||
.and_then(|item| item.downcast::<Editor>())
|
||||
.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<Workspace>,
|
||||
build_notification: impl FnOnce(&mut Context<MessageNotification>) -> 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::<str>::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::<Editor>(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<_>>(),
|
||||
Vec::<&Arc<str>>::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<_>>(),
|
||||
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::<MessageNotification>()
|
||||
.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<AppState> {
|
||||
cx.update(|cx| {
|
||||
let app_state = AppState::test(cx);
|
||||
AppState::set_global(app_state.clone(), cx);
|
||||
cx.update_global::<SettingsStore, _>(|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<AppState>, 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<AppState>,
|
||||
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<AppState>,
|
||||
cx: &'a mut TestAppContext,
|
||||
) -> (Entity<Workspace>, &'a mut VisualTestContext) {
|
||||
let fs = app_state.fs.as_fake();
|
||||
fs.insert_tree(
|
||||
path!("/root"),
|
||||
json!({
|
||||
"index.html": "<div></div>",
|
||||
"other.html": "<span></span>",
|
||||
"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<Workspace>, 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<Workspace>, 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<NotificationId> {
|
||||
let app_state = init_test(cx);
|
||||
cx.update(|cx| {
|
||||
cx.update_global::<SettingsStore, _>(|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<Workspace>,
|
||||
cx: &VisualTestContext,
|
||||
) -> Vec<NotificationId> {
|
||||
workspace.read_with(cx, |workspace, _| workspace.notification_ids())
|
||||
}
|
||||
|
||||
fn notification_views(workspace: &Entity<Workspace>, cx: &VisualTestContext) -> Vec<AnyView> {
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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<NotificationId> {
|
||||
self.notifications
|
||||
@@ -80,6 +86,14 @@ impl Workspace {
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
pub fn notification_views(&self) -> Vec<AnyView> {
|
||||
self.notifications
|
||||
.iter()
|
||||
.map(|(_, view)| view.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn show_notification<V: Notification>(
|
||||
&mut self,
|
||||
id: NotificationId,
|
||||
|
||||
Reference in new issue
Block a user