diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json
index db670d2655..f75b88cd29 100644
--- a/assets/keymaps/default-linux.json
+++ b/assets/keymaps/default-linux.json
@@ -834,6 +834,12 @@
"enter": "editor::ConfirmRename",
},
},
+ {
+ "context": "Editor && inline_input",
+ "bindings": {
+ "enter": "menu::Confirm",
+ },
+ },
{
"context": "Editor && showing_completions",
"bindings": {
diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json
index 3324bce7da..a74ab74020 100644
--- a/assets/keymaps/default-macos.json
+++ b/assets/keymaps/default-macos.json
@@ -887,6 +887,13 @@
"enter": "editor::ConfirmRename",
},
},
+ {
+ "context": "Editor && inline_input",
+ "use_key_equivalents": true,
+ "bindings": {
+ "enter": "menu::Confirm",
+ },
+ },
{
"context": "Editor && showing_completions",
"use_key_equivalents": true,
diff --git a/assets/keymaps/default-windows.json b/assets/keymaps/default-windows.json
index 1afd97082a..b11fd86d18 100644
--- a/assets/keymaps/default-windows.json
+++ b/assets/keymaps/default-windows.json
@@ -824,6 +824,13 @@
"enter": "editor::ConfirmRename",
},
},
+ {
+ "context": "Editor && inline_input",
+ "use_key_equivalents": true,
+ "bindings": {
+ "enter": "menu::Confirm",
+ },
+ },
{
"context": "Editor && showing_completions",
"use_key_equivalents": true,
diff --git a/crates/collab/src/rpc.rs b/crates/collab/src/rpc.rs
index 8d8f3fd306..c5693803d7 100644
--- a/crates/collab/src/rpc.rs
+++ b/crates/collab/src/rpc.rs
@@ -376,6 +376,9 @@ impl Server {
.add_request_handler(forward_read_only_project_request::)
.add_request_handler(forward_read_only_project_request::)
.add_request_handler(forward_read_only_project_request::)
+ .add_request_handler(
+ forward_read_only_project_request::,
+ )
.add_request_handler(forward_read_only_project_request::)
.add_request_handler(forward_mutating_project_request::)
.add_request_handler(
diff --git a/crates/collab/tests/integration/editor_tests.rs b/crates/collab/tests/integration/editor_tests.rs
index eabfd10373..b1b9d7e0e7 100644
--- a/crates/collab/tests/integration/editor_tests.rs
+++ b/crates/collab/tests/integration/editor_tests.rs
@@ -8,12 +8,12 @@ use editor::{
actions::{
ConfirmCodeAction, ConfirmCompletion, ConfirmRename, ContextMenuFirst, CopyFileLocation,
CopyFileName, CopyFileNameWithoutExtension, ExpandMacroRecursively, MoveToEnd, Redo,
- Rename, SelectAll, ToggleCodeActions, Undo,
+ Rename, SelectAll, ToggleCodeActions, Undo, WrapWithAbbreviation,
},
code_context_menus::CodeContextMenu,
test::{
editor_test_context::{AssertionContextManager, EditorTestContext},
- expand_macro_recursively,
+ expand_macro_recursively, wrap_with_abbreviation,
},
};
use fs::Fs;
@@ -24,12 +24,16 @@ use gpui::{
VisualTestContext,
};
use indoc::indoc;
-use language::{FakeLspAdapter, language_settings::LanguageSettings, rust_lang};
+use language::{
+ FakeLspAdapter, Language, LanguageConfig, LanguageMatcher, language_settings::LanguageSettings,
+ rust_lang,
+};
use lsp::DEFAULT_LSP_REQUEST_TIMEOUT;
use multi_buffer::{AnchorRangeExt as _, MultiBufferRow};
use pretty_assertions::assert_eq;
use project::{
ProgressToken, ProjectPath, SERVER_PROGRESS_THROTTLE_TIMEOUT,
+ lsp_store::emmet_ext::LspExpandAbbreviation,
lsp_store::lsp_ext_command::{ExpandedMacro, LspExtExpandMacro},
trusted_worktrees::{PathTrust, TrustedWorktrees},
};
@@ -5144,6 +5148,154 @@ async fn test_client_can_query_lsp_ext(cx_a: &mut TestAppContext, cx_b: &mut Tes
});
}
+#[gpui::test]
+async fn test_client_can_wrap_with_emmet_abbreviation(
+ cx_a: &mut TestAppContext,
+ cx_b: &mut TestAppContext,
+) {
+ let mut server = TestServer::start(cx_a.executor()).await;
+ let client_a = server.create_client(cx_a, "user_a").await;
+ let client_b = server.create_client(cx_b, "user_b").await;
+ server
+ .create_room(&mut [(&client_a, cx_a), (&client_b, cx_b)])
+ .await;
+ let active_call_a = cx_a.read(ActiveCall::global);
+ let active_call_b = cx_b.read(ActiveCall::global);
+
+ cx_b.update(editor::init);
+
+ let html_lang = || {
+ Arc::new(Language::new(
+ LanguageConfig {
+ name: "HTML".into(),
+ matcher: LanguageMatcher {
+ path_suffixes: vec!["html".into()],
+ ..LanguageMatcher::default()
+ }
+ .into(),
+ ..LanguageConfig::default()
+ },
+ None,
+ ))
+ };
+ client_a.language_registry().add(html_lang());
+ client_b.language_registry().add(html_lang());
+ let mut fake_html_servers = client_a.language_registry().register_fake_lsp(
+ "HTML",
+ FakeLspAdapter {
+ name: "vscode-html-language-server",
+ ..FakeLspAdapter::default()
+ },
+ );
+ let mut fake_emmet_servers = client_a.language_registry().register_fake_lsp(
+ "HTML",
+ FakeLspAdapter {
+ name: "emmet-language-server",
+ ..FakeLspAdapter::default()
+ },
+ );
+ client_b.language_registry().register_fake_lsp_adapter(
+ "HTML",
+ FakeLspAdapter {
+ name: "emmet-language-server",
+ ..FakeLspAdapter::default()
+ },
+ );
+
+ client_a
+ .fs()
+ .insert_tree(
+ path!("/a"),
+ json!({
+ "index.html": "hello
",
+ }),
+ )
+ .await;
+ let (project_a, worktree_id) = client_a.build_local_project(path!("/a"), cx_a).await;
+ active_call_a
+ .update(cx_a, |call, cx| call.set_location(Some(&project_a), cx))
+ .await
+ .unwrap();
+ let project_id = active_call_a
+ .update(cx_a, |call, cx| call.share_project(project_a.clone(), cx))
+ .await
+ .unwrap();
+
+ let project_b = client_b.join_remote_project(project_id, cx_b).await;
+ active_call_b
+ .update(cx_b, |call, cx| call.set_location(Some(&project_b), cx))
+ .await
+ .unwrap();
+
+ let buffer_a = project_a
+ .update(cx_a, |project, cx| {
+ project.open_buffer((worktree_id, rel_path("index.html")), cx)
+ })
+ .await
+ .unwrap();
+
+ let (workspace_b, cx_b) = client_b.build_workspace(&project_b, cx_b);
+
+ let editor_b = workspace_b
+ .update_in(cx_b, |workspace, window, cx| {
+ workspace.open_path(
+ (worktree_id, rel_path("index.html")),
+ None,
+ true,
+ window,
+ cx,
+ )
+ })
+ .await
+ .unwrap()
+ .downcast::()
+ .unwrap();
+
+ let _fake_html_server = fake_html_servers.next().await.unwrap();
+ let fake_emmet_server = fake_emmet_servers.next().await.unwrap();
+ cx_a.run_until_parked();
+ cx_b.run_until_parked();
+
+ let mut wrap_request = fake_emmet_server.set_request_handler::(
+ |params, _| async move {
+ assert_eq!(params.abbreviation, "div.wrap");
+ assert_eq!(params.language, "html");
+ assert_eq!(params.options.text, Some(vec!["hello".to_string()]));
+ Ok(Some("hello
".to_string()))
+ },
+ );
+
+ editor_b.update_in(cx_b, |editor, window, cx| {
+ editor.change_selections(SelectionEffects::default(), window, cx, |s| {
+ s.select_ranges([Point::new(0, 3)..Point::new(0, 8)])
+ });
+ wrap_with_abbreviation(editor, &WrapWithAbbreviation, window, cx);
+ });
+ editor_b.update_in(cx_b, |editor, window, cx| {
+ let input = editor
+ .pending_inline_input()
+ .expect("emmet wrap input should be pending on the client")
+ .editor
+ .clone();
+ input.update(cx, |input, cx| input.set_text("div.wrap", window, cx));
+ editor.confirm_inline_input(window, cx);
+ });
+ wrap_request.next().await.unwrap();
+ cx_a.run_until_parked();
+ cx_b.run_until_parked();
+
+ editor_b.update(cx_b, |editor, cx| {
+ assert_eq!(editor.text(cx), "
hello
");
+ });
+ buffer_a.read_with(cx_a, |buffer, _| {
+ assert_eq!(
+ buffer.text(),
+ "
hello
",
+ "the wrap edit should sync back to the host"
+ );
+ });
+}
+
#[gpui::test]
async fn test_copy_file_name_without_extension(
cx_a: &mut TestAppContext,
diff --git a/crates/editor/src/actions.rs b/crates/editor/src/actions.rs
index 95f961be80..b3cb6fdb7e 100644
--- a/crates/editor/src/actions.rs
+++ b/crates/editor/src/actions.rs
@@ -938,6 +938,8 @@ actions!(
UnwrapSyntaxNode,
/// Wraps selections in tag specified by language.
WrapSelectionsInTag,
+ /// Wraps selections in an expanded Emmet abbreviation.
+ WrapWithAbbreviation,
/// Aligns selections from different rows into the same column
AlignSelections,
/// Saves the current location to navigation history.
diff --git a/crates/editor/src/clangd_ext.rs b/crates/editor/src/clangd_ext.rs
index fbe58b06ab..b0919d5c23 100644
--- a/crates/editor/src/clangd_ext.rs
+++ b/crates/editor/src/clangd_ext.rs
@@ -51,6 +51,7 @@ pub fn switch_source_header(
let request = proto::LspExtSwitchSourceHeader {
project_id,
buffer_id: buffer_id.to_proto(),
+ server_id: server_to_query.to_proto(),
};
let response = client
.request(request)
@@ -63,7 +64,9 @@ pub fn switch_source_header(
project.request_lsp(
buffer,
project::LanguageServerToQuery::Other(server_to_query),
- project::lsp_store::lsp_ext_command::SwitchSourceHeader,
+ project::lsp_store::lsp_ext_command::SwitchSourceHeader {
+ server_id: server_to_query,
+ },
cx,
)
})
diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs
index e52c283b60..df71b2e788 100644
--- a/crates/editor/src/editor.rs
+++ b/crates/editor/src/editor.rs
@@ -23,6 +23,7 @@ mod document_links;
mod document_symbols;
mod editor_settings;
mod element;
+mod emmet_ext;
mod fold;
mod folding_ranges;
mod git;
@@ -31,6 +32,7 @@ pub mod hover_links;
pub mod hover_popover;
mod indent_guides;
mod inlays;
+mod inline_input;
pub mod items;
mod jsx_tag_auto_close;
mod linked_editing_ranges;
@@ -116,6 +118,8 @@ use git::{DiffReviewDragState, DiffReviewOverlay, InlineBlamePopover};
pub(crate) use git::{DisplayDiffHunk, PhantomDiffReviewIndicator};
pub use hover_popover::hover_markdown_style;
pub use inlays::Inlay;
+pub use inline_input::InlineInputState;
+pub(crate) use inline_input::{InlineInputHistoryDirection, InlineInputPreview};
pub use items::MAX_TAB_TITLE_LEN;
pub use linked_editing_ranges::LinkedEdits;
pub use lsp::CompletionContext;
@@ -1045,6 +1049,7 @@ pub struct Editor {
linked_editing_range_task: Option>>,
linked_edit_ranges: linked_editing_ranges::LinkedEditingRanges,
pending_rename: Option,
+ pending_inline_input: Option,
searchable: bool,
cursor_shape: CursorShape,
/// Whether the cursor is offset one character to the left when something is
@@ -2385,6 +2390,7 @@ impl Editor {
document_highlights_task: None,
linked_editing_range_task: None,
pending_rename: None,
+ pending_inline_input: None,
searchable: !is_minimap,
cursor_shape: EditorSettings::get_global(cx)
.cursor_shape
@@ -2734,6 +2740,9 @@ impl Editor {
if self.pending_rename.is_some() {
key_context.add("renaming");
}
+ if self.pending_inline_input.is_some() {
+ key_context.add("inline_input");
+ }
if let Some(snippet_stack) = self.snippet_stack.last() {
key_context.add("in_snippet");
@@ -3496,6 +3505,7 @@ impl Editor {
let mut dismissed = false;
dismissed |= self.take_rename(false, window, cx).is_some();
+ dismissed |= self.take_inline_input(window, cx).is_some();
dismissed |= self.hide_blame_popover(true, cx);
dismissed |= hide_hover(self, cx);
dismissed |= self.hide_signature_help(cx, SignatureHelpHiddenBy::Escape);
@@ -4900,6 +4910,25 @@ impl Editor {
snippet: Snippet,
window: &mut Window,
cx: &mut Context,
+ ) -> Result<()> {
+ self.insert_snippet_with_autoindent(
+ insertion_ranges,
+ snippet,
+ Some(AutoindentMode::Block {
+ original_indent_columns: Vec::new(),
+ }),
+ window,
+ cx,
+ )
+ }
+
+ pub(crate) fn insert_snippet_with_autoindent(
+ &mut self,
+ insertion_ranges: &[Range],
+ snippet: Snippet,
+ autoindent_mode: Option,
+ window: &mut Window,
+ cx: &mut Context,
) -> Result<()> {
struct Tabstop {
is_end_tabstop: bool,
@@ -4913,10 +4942,7 @@ impl Editor {
.iter()
.cloned()
.map(|range| (range, snippet_text.clone()));
- let autoindent_mode = AutoindentMode::Block {
- original_indent_columns: Vec::new(),
- };
- buffer.edit(edits, Some(autoindent_mode), cx);
+ buffer.edit(edits, autoindent_mode, cx);
let snapshot = &*buffer.read(cx);
let snippet = &snippet;
diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs
index 1c618ff094..308eead26f 100644
--- a/crates/editor/src/element.rs
+++ b/crates/editor/src/element.rs
@@ -278,6 +278,13 @@ impl EditorElement {
crate::rust_analyzer_ext::apply_related_actions(editor, window, cx);
crate::clangd_ext::apply_related_actions(editor, window, cx);
+ crate::emmet_ext::apply_related_actions(editor, window, cx);
+
+ if editor.read(cx).pending_inline_input().is_some() {
+ register_action(editor, window, |editor, _: &menu::Confirm, window, cx| {
+ editor.confirm_inline_input(window, cx);
+ });
+ }
register_action(editor, window, Editor::open_context_menu);
register_action(editor, window, Editor::move_left);
diff --git a/crates/editor/src/emmet_ext.rs b/crates/editor/src/emmet_ext.rs
new file mode 100644
index 0000000000..bc0d605e0d
--- /dev/null
+++ b/crates/editor/src/emmet_ext.rs
@@ -0,0 +1,1628 @@
+use std::ops::Range;
+use std::time::Duration;
+
+use futures::future::join_all;
+use gpui::{App, AsyncWindowContext, Context, Entity, Global, SharedString, Task, Window};
+use language::{Buffer, Language};
+use lsp::LanguageServerId;
+use multi_buffer::{
+ Anchor, MultiBufferOffset, MultiBufferRow, MultiBufferSnapshot, ToOffset, ToPoint,
+};
+use project::Project;
+use project::lsp_store::emmet_ext::{EMMET_SERVER_NAME, ExpandAbbreviation};
+use snippet::Snippet;
+use text::Point;
+use util::ResultExt as _;
+
+use crate::scroll::Autoscroll;
+use crate::{
+ Editor, InlineInputPreview, SelectionEffects, WrapWithAbbreviation, element::register_action,
+};
+
+const PREVIEW_DEBOUNCE: Duration = Duration::from_millis(150);
+const MAX_HISTORY_ENTRIES: usize = 20;
+
+const ELEMENT_NODE_KINDS: [&str; 5] = [
+ "element",
+ "jsx_element",
+ "jsx_self_closing_element",
+ "script_element",
+ "style_element",
+];
+
+const TAG_NODE_KINDS: [&str; 5] = [
+ "start_tag",
+ "end_tag",
+ "self_closing_tag",
+ "jsx_opening_element",
+ "jsx_closing_element",
+];
+
+const STYLESHEET_LANGUAGE_IDS: [&str; 6] = ["css", "sass", "scss", "less", "sss", "stylus"];
+
+#[derive(Clone, Copy, Default)]
+struct WrapFilters {
+ trim: bool,
+ comment: bool,
+ bem: bool,
+}
+
+#[derive(Default)]
+struct WrapAbbreviationHistory(Vec);
+
+impl Global for WrapAbbreviationHistory {}
+
+pub fn apply_related_actions(editor: &Entity, window: &mut Window, cx: &mut App) {
+ let editor_ref = editor.read(cx);
+ let Some(project) = editor_ref.project() else {
+ return;
+ };
+ let project = project.read(cx);
+ if !project
+ .language_server_statuses(cx)
+ .any(|(_, status)| status.name == EMMET_SERVER_NAME)
+ {
+ return;
+ }
+ let supports_wrap = editor_ref
+ .buffer()
+ .read(cx)
+ .all_buffers_iter()
+ .any(|buffer| {
+ buffer
+ .read(cx)
+ .language()
+ .is_some_and(|language| language_supports_wrap(language, project))
+ });
+ if supports_wrap {
+ register_action(editor, window, wrap_with_abbreviation);
+ }
+}
+
+pub fn wrap_with_abbreviation(
+ editor: &mut Editor,
+ _: &WrapWithAbbreviation,
+ window: &mut Window,
+ cx: &mut Context,
+) {
+ if editor.read_only(cx) {
+ return;
+ }
+
+ let selections = editor.selections.disjoint_anchors_arc();
+ let snapshot = editor.buffer().read(cx).snapshot(cx);
+ let mut offset_ranges = Vec::>::new();
+ for selection in selections.iter() {
+ let mut range = selection.start.to_offset(&snapshot)..selection.end.to_offset(&snapshot);
+ if range.start == range.end {
+ range = enclosing_element_range(range.start, &snapshot).unwrap_or_else(|| {
+ let row = MultiBufferRow(selection.start.to_point(&snapshot).row);
+ Point::new(row.0, 0).to_offset(&snapshot)
+ ..Point::new(row.0, snapshot.line_len(row)).to_offset(&snapshot)
+ });
+ } else {
+ range = expand_over_partial_tags(range, &snapshot);
+ }
+ offset_ranges.push(trim_whitespace(range, &snapshot));
+ }
+ offset_ranges.sort_by_key(|range| (range.start, range.end));
+ let mut merged_ranges = Vec::>::with_capacity(offset_ranges.len());
+ for range in offset_ranges {
+ match merged_ranges.last_mut() {
+ Some(last) if range.start < last.end || range.start == last.start => {
+ last.end = last.end.max(range.end);
+ }
+ _ => merged_ranges.push(range),
+ }
+ }
+ let ranges = merged_ranges
+ .into_iter()
+ .map(|range| snapshot.anchor_before(range.start)..snapshot.anchor_after(range.end))
+ .collect::>();
+ if ranges.is_empty() {
+ return;
+ }
+ if !ranges.iter().any(|range| {
+ build_wrap_target(editor, range, WrapFilters::default(), &snapshot, cx).is_some()
+ }) {
+ log_unavailable();
+ return;
+ }
+
+ editor.change_selections(SelectionEffects::default(), window, cx, |selections| {
+ selections.select_anchor_ranges(ranges.iter().cloned())
+ });
+ let newest = editor.selections.newest_anchor();
+ let start_column = newest.start.to_point(&snapshot).column;
+ let end_row = MultiBufferRow(newest.end.to_point(&snapshot).row);
+ let position = snapshot.anchor_before(Point::new(
+ end_row.0,
+ start_column.min(snapshot.line_len(end_row)),
+ ));
+ let preview_language = snapshot.language_at(position).cloned();
+ let history = cx
+ .try_global::()
+ .map(|history| history.0.clone())
+ .unwrap_or_default();
+ let confirm_ranges = ranges.clone();
+ let preview_ranges = ranges;
+ editor.show_inline_input(
+ "Emmet abbreviation, e.g. ul>li*",
+ position,
+ preview_language,
+ history,
+ move |editor, text, window, cx| {
+ wrap_targets_in_expanded_abbreviation(editor, &confirm_ranges, text, window, cx)
+ },
+ move |editor, text, window, cx| {
+ update_expansion_preview(editor, &preview_ranges, text, window, cx)
+ },
+ window,
+ cx,
+ );
+}
+
+struct WrapTarget {
+ range: Range,
+ text: Option>,
+ buffer: Entity,
+ server_id: LanguageServerId,
+ language: String,
+ indent: String,
+ base_indent: String,
+}
+
+fn language_supports_wrap(language: &Language, project: &Project) -> bool {
+ project
+ .languages()
+ .lsp_adapter(&language.name(), &EMMET_SERVER_NAME)
+ .is_some_and(|adapter| {
+ !STYLESHEET_LANGUAGE_IDS.contains(&adapter.language_id(&language.name()).as_str())
+ })
+}
+
+fn build_wrap_target(
+ editor: &Editor,
+ range: &Range,
+ filters: WrapFilters,
+ snapshot: &MultiBufferSnapshot,
+ cx: &App,
+) -> Option {
+ let project = editor.project.as_ref()?.read(cx);
+ let multi_buffer = editor.buffer().read(cx);
+ let (buffer, start) = multi_buffer.text_anchor_for_position(range.start, cx)?;
+ let (end_buffer, _) = multi_buffer.text_anchor_for_position(range.end, cx)?;
+ if buffer != end_buffer {
+ return None;
+ }
+ let server_id = project.language_server_id_for_name(buffer.read(cx), &EMMET_SERVER_NAME, cx)?;
+ let language = buffer
+ .read(cx)
+ .language_at(start)
+ .or_else(|| buffer.read(cx).language().cloned())?;
+ let adapter = project
+ .lsp_store()
+ .read(cx)
+ .language_server_adapter_for_id(server_id)?;
+ let language = adapter.language_id(&language.name());
+ if STYLESHEET_LANGUAGE_IDS.contains(&language.as_str()) {
+ return None;
+ }
+ let text = snapshot.text_for_range(range.clone()).collect::();
+ let settings = snapshot.language_settings_at(range.start, cx);
+ let indent = if settings.hard_tabs {
+ "\t".to_string()
+ } else {
+ " ".repeat(settings.tab_size.get() as usize)
+ };
+ let base_indent = base_indent_for(range.start, snapshot);
+ Some(WrapTarget {
+ text: selection_lines(&text, &base_indent, filters.trim),
+ range: range.clone(),
+ buffer,
+ server_id,
+ language,
+ indent,
+ base_indent,
+ })
+}
+
+fn selection_lines(text: &str, base_indent: &str, trim_markers: bool) -> Option> {
+ if text.is_empty() {
+ return None;
+ }
+ let lines = text
+ .split('\n')
+ .enumerate()
+ .map(|(ix, line)| {
+ let line = line.trim_end();
+ let line = if ix == 0 {
+ line
+ } else {
+ line.strip_prefix(base_indent).unwrap_or(line)
+ };
+ let line = if trim_markers {
+ trim_list_marker(line)
+ } else {
+ line
+ };
+ line.replace('\\', "\\\\").replace('$', "\\$")
+ })
+ .collect::>();
+ Some(lines)
+}
+
+fn trim_list_marker(line: &str) -> &str {
+ let trimmed = line.trim_start_matches([' ', '\t', '\u{a0}']);
+ let marker_len = trimmed
+ .chars()
+ .take_while(|ch| ch.is_ascii_digit() || matches!(ch, '#' | '-' | '*' | '•'))
+ .map(char::len_utf8)
+ .sum::();
+ if marker_len == 0 {
+ return line.trim();
+ }
+ let rest = &trimmed[marker_len..];
+ let rest = rest.strip_prefix(['.', ')']).unwrap_or(rest);
+ rest.trim()
+}
+
+fn parse_abbreviation(input: &str) -> Option<(String, WrapFilters)> {
+ let mut abbreviation = input.trim();
+ let mut filters = WrapFilters::default();
+ loop {
+ if let Some(stripped) = abbreviation.strip_suffix("|t") {
+ filters.trim = true;
+ abbreviation = stripped.trim_end();
+ } else if let Some(stripped) = abbreviation.strip_suffix("|c") {
+ filters.comment = true;
+ abbreviation = stripped.trim_end();
+ } else if let Some(stripped) = abbreviation.strip_suffix("|bem") {
+ filters.bem = true;
+ abbreviation = stripped.trim_end();
+ } else {
+ break;
+ }
+ }
+ if abbreviation.is_empty() {
+ return None;
+ }
+ Some((abbreviation.to_string(), filters))
+}
+
+fn wrap_targets_in_expanded_abbreviation(
+ editor: &mut Editor,
+ ranges: &[Range],
+ text: String,
+ window: &mut Window,
+ cx: &mut Context,
+) -> Option> {
+ let input = text.trim().to_string();
+ let (abbreviation, filters) = parse_abbreviation(&input)?;
+ let project = editor.project.clone()?;
+
+ let snapshot = editor.buffer().read(cx).snapshot(cx);
+ let targets = ranges
+ .iter()
+ .filter_map(|range| build_wrap_target(editor, range, filters, &snapshot, cx))
+ .collect::>();
+ if targets.is_empty() {
+ log_unavailable();
+ editor.take_inline_input(window, cx);
+ return None;
+ }
+
+ Some(cx.spawn_in(window, async move |editor, cx| {
+ let requests = targets
+ .into_iter()
+ .map(|target| {
+ let range = target.range.clone();
+ let request =
+ request_expansion(&project, abbreviation.clone(), filters, target, cx);
+ async move { anyhow::Ok((range, request.await?)) }
+ })
+ .collect::>();
+ let mut expansions = Vec::new();
+ let mut errors = Vec::new();
+ for result in join_all(requests).await {
+ match result {
+ Ok((range, Some(expansion))) => expansions.push((range, expansion)),
+ Ok((_, None)) => {}
+ Err(error) => errors.push(error),
+ }
+ }
+
+ editor
+ .update_in(cx, |editor, window, cx| {
+ for error in &errors {
+ log::error!("{error:#}");
+ }
+ if expansions.is_empty() {
+ let message = match errors.first() {
+ Some(error) => error.root_cause().to_string(),
+ None => no_expansion_message(&abbreviation),
+ };
+ editor.set_inline_input_preview(Some(InlineInputPreview::Error(message)), cx);
+ return anyhow::Ok(());
+ }
+ remember_abbreviation(SharedString::from(input), cx);
+ if let Some(mut state) = editor.take_inline_input(window, cx)
+ && let Some(confirm_task) = state.take_confirm_task()
+ {
+ confirm_task.detach();
+ }
+ let snapshot = editor.buffer().read(cx).snapshot(cx);
+ if let [(range, expansion)] = expansions.as_slice() {
+ if let Ok(snippet) = Snippet::parse(expansion) {
+ let range =
+ range.start.to_offset(&snapshot)..range.end.to_offset(&snapshot);
+ editor.insert_snippet_with_autoindent(
+ &[range],
+ snippet,
+ None,
+ window,
+ cx,
+ )?;
+ return anyhow::Ok(());
+ }
+ }
+ let edits = expansions
+ .into_iter()
+ .map(|(range, expansion)| (range, expansion_text(&expansion)))
+ .collect::>();
+ editor.transact(window, cx, |editor, _, cx| {
+ editor.buffer().update(cx, |buffer, cx| {
+ buffer.edit(edits, None, cx);
+ });
+ });
+ editor.request_autoscroll(Autoscroll::fit(), cx);
+ anyhow::Ok(())
+ })
+ .and_then(|result| result)
+ .log_err();
+ }))
+}
+
+fn update_expansion_preview(
+ editor: &mut Editor,
+ ranges: &[Range],
+ text: String,
+ window: &mut Window,
+ cx: &mut Context,
+) -> Option> {
+ let Some((abbreviation, filters)) = parse_abbreviation(&text) else {
+ editor.set_inline_input_preview(None, cx);
+ return None;
+ };
+ let Some(project) = editor.project.clone() else {
+ editor.set_inline_input_preview(None, cx);
+ return None;
+ };
+ let snapshot = editor.buffer().read(cx).snapshot(cx);
+ let Some(mut target) = ranges
+ .iter()
+ .find_map(|range| build_wrap_target(editor, range, filters, &snapshot, cx))
+ else {
+ editor.set_inline_input_preview(None, cx);
+ return None;
+ };
+ target.base_indent.clear();
+
+ Some(cx.spawn_in(window, async move |editor, cx| {
+ cx.background_executor().timer(PREVIEW_DEBOUNCE).await;
+ let result = request_expansion(&project, abbreviation.clone(), filters, target, cx).await;
+ editor
+ .update(cx, |editor, cx| {
+ let preview = match result {
+ Ok(Some(expansion)) => InlineInputPreview::Text(expansion_text(&expansion)),
+ Ok(None) => InlineInputPreview::Error(no_expansion_message(&abbreviation)),
+ Err(error) => InlineInputPreview::Error(error.root_cause().to_string()),
+ };
+ editor.set_inline_input_preview(Some(preview), cx);
+ })
+ .ok();
+ }))
+}
+
+fn request_expansion(
+ project: &Entity,
+ abbreviation: String,
+ filters: WrapFilters,
+ target: WrapTarget,
+ cx: &mut AsyncWindowContext,
+) -> Task>> {
+ project.update(cx, |project, cx| {
+ project.request_lsp(
+ target.buffer,
+ project::LanguageServerToQuery::Other(target.server_id),
+ ExpandAbbreviation {
+ abbreviation,
+ text: target.text,
+ language: target.language,
+ server_id: target.server_id,
+ indent: target.indent,
+ base_indent: target.base_indent,
+ comment_filter: filters.comment,
+ bem_filter: filters.bem,
+ },
+ cx,
+ )
+ })
+}
+
+fn expansion_text(expansion: &str) -> String {
+ Snippet::parse(expansion)
+ .map(|snippet| snippet.text)
+ .unwrap_or_else(|_| expansion.to_string())
+}
+
+fn no_expansion_message(abbreviation: &str) -> String {
+ format!("No Emmet expansion for {abbreviation:?}")
+}
+
+fn remember_abbreviation(abbreviation: SharedString, cx: &mut App) {
+ let history = &mut cx.default_global::().0;
+ history.retain(|entry| *entry != abbreviation);
+ history.insert(0, abbreviation);
+ history.truncate(MAX_HISTORY_ENTRIES);
+}
+
+fn log_unavailable() {
+ log::info!("Wrapping with an Emmet abbreviation is not available for the selected text");
+}
+
+fn base_indent_for(range_start: Anchor, snapshot: &MultiBufferSnapshot) -> String {
+ let row = MultiBufferRow(range_start.to_point(snapshot).row);
+ snapshot.indent_size_for_line(row).chars().collect()
+}
+
+fn enclosing_element_range(
+ position: MultiBufferOffset,
+ snapshot: &MultiBufferSnapshot,
+) -> Option> {
+ let mut range = position..position;
+ while let Some((node, node_range)) = snapshot.syntax_ancestor(range.clone()) {
+ if ELEMENT_NODE_KINDS.contains(&node.kind()) {
+ return Some(node_range);
+ }
+ if node_range == range {
+ return None;
+ }
+ range = node_range;
+ }
+ None
+}
+
+fn expand_over_partial_tags(
+ range: Range,
+ snapshot: &MultiBufferSnapshot,
+) -> Range {
+ let mut expanded = range.clone();
+ for position in [range.start, range.end] {
+ if let Some(element_range) = element_range_for_partial_tag(position, snapshot) {
+ expanded.start = expanded.start.min(element_range.start);
+ expanded.end = expanded.end.max(element_range.end);
+ }
+ }
+ expanded
+}
+
+fn element_range_for_partial_tag(
+ position: MultiBufferOffset,
+ snapshot: &MultiBufferSnapshot,
+) -> Option> {
+ let mut range = position..position;
+ let mut in_tag = false;
+ while let Some((node, node_range)) = snapshot.syntax_ancestor(range.clone()) {
+ if TAG_NODE_KINDS.contains(&node.kind()) {
+ if node_range.start < position && position < node_range.end {
+ in_tag = true;
+ }
+ } else if ELEMENT_NODE_KINDS.contains(&node.kind()) {
+ return in_tag.then_some(node_range);
+ }
+ if node_range == range {
+ return None;
+ }
+ range = node_range;
+ }
+ None
+}
+
+fn trim_whitespace(
+ mut range: Range,
+ snapshot: &MultiBufferSnapshot,
+) -> Range {
+ for ch in snapshot.chars_at(range.start) {
+ if range.start == range.end || !ch.is_whitespace() {
+ break;
+ }
+ range.start += ch.len_utf8();
+ }
+ for ch in snapshot.reversed_chars_at(range.end) {
+ if range.end == range.start || !ch.is_whitespace() {
+ break;
+ }
+ range.end -= ch.len_utf8();
+ }
+ range
+}
+
+#[cfg(test)]
+mod tests {
+ use std::sync::Arc;
+
+ use futures::StreamExt as _;
+ use gpui::{AppContext as _, Focusable as _, TestAppContext, VisualTestContext};
+ use language::{Capability, FakeLspAdapter, Language, LanguageConfig, LanguageMatcher};
+ use multi_buffer::{MultiBuffer, PathKey};
+ use project::lsp_store::emmet_ext::{EmmetOutputOptions, LspExpandAbbreviation};
+ use project::{FakeFs, Project};
+ use util::path;
+
+ use crate::editor_tests::init_test;
+ use crate::test::build_editor_with_project;
+
+ use super::*;
+
+ #[gpui::test]
+ async fn test_wrap_selection_in_emmet_abbreviation(cx: &mut TestAppContext) {
+ let (editor, mut fake_servers, cx) = setup("hello
", cx).await;
+ let fake_server = fake_servers.next().await.unwrap();
+ cx.executor().run_until_parked();
+
+ select_and_wrap(&editor, [Point::new(0, 3)..Point::new(0, 8)], cx);
+
+ let mut requests = fake_server.set_request_handler::(
+ |params, _| async move {
+ assert_eq!(params.abbreviation, "div.wrap");
+ assert_eq!(params.language, "html");
+ assert_eq!(params.options.text, Some(vec!["hello".to_string()]));
+ assert_eq!(
+ params.options.options,
+ EmmetOutputOptions {
+ indent: " ".to_string(),
+ base_indent: String::new(),
+ ..EmmetOutputOptions::default()
+ },
+ "single-line wraps should not force inline breaks"
+ );
+ Ok(Some("${1:hello}
$0".to_string()))
+ },
+ );
+
+ editor.update_in(cx, |editor, window, cx| {
+ let input = pending_input(editor);
+ assert!(input.focus_handle(cx).is_focused(window));
+ input.update(cx, |input, cx| input.set_text("div.wrap", window, cx));
+ });
+ cx.update(|_, cx| {
+ cx.bind_keys([gpui::KeyBinding::new(
+ "enter",
+ menu::Confirm,
+ Some("Editor && inline_input"),
+ )]);
+ });
+ cx.simulate_keystrokes("enter");
+ requests.next().await.unwrap();
+ cx.executor().run_until_parked();
+
+ editor.update(cx, |editor, cx| {
+ assert_eq!(editor.text(cx), "
hello
");
+ assert!(editor.pending_inline_input.is_none());
+ });
+ }
+
+ #[gpui::test]
+ async fn test_input_stays_open_on_empty_expansion(cx: &mut TestAppContext) {
+ let (editor, mut fake_servers, cx) = setup("hello
", cx).await;
+ let fake_server = fake_servers.next().await.unwrap();
+ cx.executor().run_until_parked();
+
+ select_and_wrap(&editor, [Point::new(0, 3)..Point::new(0, 8)], cx);
+
+ let mut requests = fake_server
+ .set_request_handler::(|_, _| async move { Ok(None) });
+
+ confirm_abbreviation(&editor, "@invalid@", cx);
+ requests.next().await.unwrap();
+ cx.executor().run_until_parked();
+
+ editor.update(cx, |editor, cx| {
+ assert_eq!(editor.text(cx), "hello
");
+ assert!(
+ editor.pending_inline_input.is_some(),
+ "the input should stay open so the abbreviation can be corrected"
+ );
+ assert_eq!(
+ editor
+ .pending_inline_input
+ .as_ref()
+ .and_then(|state| state.preview.clone()),
+ Some(InlineInputPreview::Error(
+ "No Emmet expansion for \"@invalid@\"".to_string()
+ )),
+ "the failure should show up next to the input"
+ );
+ });
+ }
+
+ #[gpui::test]
+ async fn test_wrap_line_on_empty_selection(cx: &mut TestAppContext) {
+ let (editor, mut fake_servers, cx) = setup(" hello world\n", cx).await;
+ let fake_server = fake_servers.next().await.unwrap();
+ cx.executor().run_until_parked();
+
+ select_and_wrap(&editor, [Point::new(0, 4)..Point::new(0, 4)], cx);
+
+ let mut requests = fake_server.set_request_handler::(
+ |params, _| async move {
+ assert_eq!(params.abbreviation, "strong");
+ assert_eq!(params.options.text, Some(vec!["hello world".to_string()]));
+ Ok(Some("hello world ".to_string()))
+ },
+ );
+
+ confirm_abbreviation(&editor, "strong", cx);
+ requests.next().await.unwrap();
+ cx.executor().run_until_parked();
+
+ editor.update(cx, |editor, cx| {
+ assert_eq!(editor.text(cx), " hello world \n");
+ });
+ }
+
+ #[gpui::test]
+ async fn test_wrap_enclosing_element_on_empty_selection(cx: &mut TestAppContext) {
+ let (editor, mut fake_servers, cx) = setup("", cx).await;
+ let fake_server = fake_servers.next().await.unwrap();
+ cx.executor().run_until_parked();
+
+ select_and_wrap(&editor, [Point::new(1, 3)..Point::new(1, 3)], cx);
+ editor.update(cx, |editor, cx| {
+ let snapshot = editor.display_snapshot(cx);
+ assert_eq!(
+ editor.selections.newest::(&snapshot).range(),
+ Point::new(1, 2)..Point::new(1, 14),
+ "the element about to be wrapped should be selected so it is visible"
+ );
+ });
+
+ let mut requests = fake_server.set_request_handler::(
+ |params, _| async move {
+ assert_eq!(params.abbreviation, "div.x");
+ assert_eq!(
+ params.options.text,
+ Some(vec!["hello
".to_string()]),
+ "the enclosing element should be wrapped, not the line"
+ );
+ Ok(Some("".to_string()))
+ },
+ );
+
+ confirm_abbreviation(&editor, "div.x", cx);
+ requests.next().await.unwrap();
+ cx.executor().run_until_parked();
+
+ editor.update(cx, |editor, cx| {
+ assert_eq!(
+ editor.text(cx),
+ ""
+ );
+ });
+ }
+
+ #[gpui::test]
+ async fn test_multiline_expansion_preserves_surrounding_indentation(cx: &mut TestAppContext) {
+ let (editor, mut fake_servers, cx) =
+ setup("", cx).await;
+ let fake_server = fake_servers.next().await.unwrap();
+ cx.executor().run_until_parked();
+
+ select_and_wrap(&editor, [Point::new(1, 2)..Point::new(2, 10)], cx);
+
+ let mut requests = fake_server.set_request_handler::(
+ |params, _| async move {
+ assert_eq!(params.abbreviation, "div");
+ assert_eq!(
+ params.options.text,
+ Some(vec!["a
".to_string(), "b
".to_string()]),
+ "lines should be dedented by the base indentation of the first line"
+ );
+ assert_eq!(
+ params.options.options.base_indent, " ",
+ "the wrapped line's indentation should be sent as the base indent"
+ );
+ Ok(Some(format_expansion(
+ "",
+ ¶ms.options.options,
+ )))
+ },
+ );
+
+ confirm_abbreviation(&editor, "div", cx);
+ requests.next().await.unwrap();
+ cx.executor().run_until_parked();
+
+ editor.update(cx, |editor, cx| {
+ assert_eq!(
+ editor.text(cx),
+ "",
+ "inserted lines should be indented relative to the wrapped line, \
+ and surrounding indentation should not change"
+ );
+ });
+ }
+
+ #[gpui::test]
+ async fn test_escape_dismisses_emmet_wrap_input(cx: &mut TestAppContext) {
+ let (editor, mut fake_servers, cx) = setup("hello
", cx).await;
+ fake_servers.next().await.unwrap();
+ cx.executor().run_until_parked();
+
+ select_and_wrap(&editor, [Point::new(0, 3)..Point::new(0, 8)], cx);
+ editor.update_in(cx, |editor, window, cx| {
+ assert!(editor.pending_inline_input.is_some());
+ editor.cancel(&crate::actions::Cancel, window, cx);
+ assert!(editor.pending_inline_input.is_none());
+ assert_eq!(editor.text(cx), "hello
");
+ });
+ }
+
+ #[gpui::test]
+ async fn test_multiple_cursors_on_blank_line_wrap_once(cx: &mut TestAppContext) {
+ let (editor, mut fake_servers, cx) = setup(" \n", cx).await;
+ let fake_server = fake_servers.next().await.unwrap();
+ cx.executor().run_until_parked();
+
+ select_and_wrap(
+ &editor,
+ [
+ Point::new(0, 0)..Point::new(0, 0),
+ Point::new(0, 2)..Point::new(0, 2),
+ ],
+ cx,
+ );
+
+ let mut requests = fake_server.set_request_handler::(
+ |params, _| async move {
+ assert_eq!(params.abbreviation, "div");
+ assert_eq!(params.options.text, None);
+ Ok(Some("
".to_string()))
+ },
+ );
+
+ confirm_abbreviation(&editor, "div", cx);
+ requests.next().await.unwrap();
+ cx.executor().run_until_parked();
+
+ editor.update(cx, |editor, cx| {
+ assert_eq!(editor.text(cx), "
\n");
+ assert!(editor.pending_inline_input.is_none());
+ });
+ }
+
+ #[gpui::test]
+ async fn test_stale_confirmation_is_ignored(cx: &mut TestAppContext) {
+ let (editor, mut fake_servers, cx) = setup("hello
", cx).await;
+ let fake_server = fake_servers.next().await.unwrap();
+ cx.executor().run_until_parked();
+
+ select_and_wrap(&editor, [Point::new(0, 3)..Point::new(0, 8)], cx);
+
+ let (release_fresh, fresh_gate) = futures::channel::oneshot::channel::<()>();
+ let fresh_gate = Arc::new(std::sync::Mutex::new(Some(fresh_gate)));
+ fake_server.set_request_handler::({
+ move |params, _| {
+ let fresh_gate = fresh_gate.clone();
+ async move {
+ if params.abbreviation == "span" {
+ let gate = fresh_gate.lock().unwrap().take();
+ if let Some(gate) = gate {
+ gate.await.ok();
+ }
+ Ok(Some("hello ".to_string()))
+ } else {
+ assert_eq!(params.abbreviation, "div");
+ Ok(Some("hello
".to_string()))
+ }
+ }
+ }
+ });
+
+ confirm_abbreviation(&editor, "div", cx);
+ confirm_abbreviation(&editor, "span", cx);
+ cx.executor().run_until_parked();
+
+ editor.update(cx, |editor, cx| {
+ assert_eq!(
+ editor.text(cx),
+ "hello
",
+ "the stale expansion should be dropped while the fresh one is in flight"
+ );
+ assert!(editor.pending_inline_input.is_some());
+ });
+
+ release_fresh.send(()).ok();
+ cx.executor().run_until_parked();
+
+ editor.update(cx, |editor, cx| {
+ assert_eq!(editor.text(cx), "hello
");
+ assert!(editor.pending_inline_input.is_none());
+ });
+ }
+
+ #[gpui::test]
+ async fn test_preview_updates_as_you_type(cx: &mut TestAppContext) {
+ let (editor, mut fake_servers, cx) = setup("hello
", cx).await;
+ let fake_server = fake_servers.next().await.unwrap();
+ cx.executor().run_until_parked();
+
+ select_and_wrap(&editor, [Point::new(0, 3)..Point::new(0, 8)], cx);
+
+ let mut requests = fake_server.set_request_handler::(
+ |params, _| async move {
+ assert_eq!(params.abbreviation, "div.wrap");
+ assert_eq!(params.options.text, Some(vec!["hello".to_string()]));
+ Ok(Some("${1:hello}
$0".to_string()))
+ },
+ );
+
+ editor.update_in(cx, |editor, window, cx| {
+ let input = pending_input(editor);
+ input.update(cx, |input, cx| input.set_text("div.wrap", window, cx));
+ });
+ cx.executor().advance_clock(PREVIEW_DEBOUNCE * 2);
+ requests.next().await.unwrap();
+ cx.executor().run_until_parked();
+
+ editor.update(cx, |editor, cx| {
+ assert_eq!(
+ editor
+ .pending_inline_input
+ .as_ref()
+ .and_then(|state| state.preview.clone()),
+ Some(InlineInputPreview::Text(
+ "hello
".to_string()
+ )),
+ "the pending expansion should be previewed under the input"
+ );
+ assert_eq!(
+ editor.text(cx),
+ "hello
",
+ "previewing should not edit the buffer"
+ );
+ });
+
+ editor.update_in(cx, |editor, window, cx| {
+ let input = pending_input(editor);
+ input.update(cx, |input, cx| input.set_text("", window, cx));
+ });
+ cx.executor().run_until_parked();
+ editor.update(cx, |editor, _| {
+ assert_eq!(
+ editor
+ .pending_inline_input
+ .as_ref()
+ .and_then(|state| state.preview.clone()),
+ None,
+ "clearing the input should clear the preview"
+ );
+ });
+ }
+
+ #[gpui::test]
+ async fn test_preview_does_not_embed_base_indentation(cx: &mut TestAppContext) {
+ let (editor, mut fake_servers, cx) =
+ setup("", cx).await;
+ let fake_server = fake_servers.next().await.unwrap();
+ cx.executor().run_until_parked();
+
+ select_and_wrap(&editor, [Point::new(1, 2)..Point::new(2, 10)], cx);
+
+ let mut requests = fake_server.set_request_handler::(
+ |params, _| async move {
+ assert_eq!(params.abbreviation, "div");
+ assert_eq!(
+ params.options.options.base_indent, "",
+ "previews render at the anchor column already, \
+ so they must request no base indent"
+ );
+ Ok(Some(format_expansion(
+ "",
+ ¶ms.options.options,
+ )))
+ },
+ );
+
+ editor.update_in(cx, |editor, window, cx| {
+ let input = pending_input(editor);
+ input.update(cx, |input, cx| input.set_text("div", window, cx));
+ });
+ cx.executor().advance_clock(PREVIEW_DEBOUNCE * 2);
+ requests.next().await.unwrap();
+ cx.executor().run_until_parked();
+
+ editor.update(cx, |editor, _| {
+ assert_eq!(
+ editor
+ .pending_inline_input
+ .as_ref()
+ .and_then(|state| state.preview.clone()),
+ Some(InlineInputPreview::Text(
+ "".to_string()
+ )),
+ "the preview block already renders at the anchor column, \
+ so its lines must not repeat the wrapped line's base indentation"
+ );
+ });
+ }
+
+ #[gpui::test]
+ async fn test_last_abbreviation_is_prefilled(cx: &mut TestAppContext) {
+ let (editor, mut fake_servers, cx) = setup("hello
", cx).await;
+ let fake_server = fake_servers.next().await.unwrap();
+ cx.executor().run_until_parked();
+
+ select_and_wrap(&editor, [Point::new(0, 3)..Point::new(0, 8)], cx);
+
+ let mut requests =
+ fake_server.set_request_handler::(|_, _| async move {
+ Ok(Some("hello
".to_string()))
+ });
+
+ confirm_abbreviation(&editor, "div.wrap", cx);
+ requests.next().await.unwrap();
+ cx.executor().run_until_parked();
+
+ editor.update(cx, |editor, cx| {
+ assert_eq!(editor.text(cx), "
hello
");
+ });
+
+ select_and_wrap(&editor, [Point::new(0, 3)..Point::new(0, 8)], cx);
+ editor.update(cx, |editor, cx| {
+ let input = pending_input(editor);
+ input.update(cx, |input, cx| {
+ assert_eq!(
+ input.text(cx),
+ "div.wrap",
+ "the last used abbreviation should be prefilled"
+ );
+ let snapshot = input.display_snapshot(cx);
+ let selected = input.selections.newest::(&snapshot);
+ assert_eq!(
+ selected.range(),
+ Point::new(0, 0)..Point::new(0, "div.wrap".len() as u32),
+ "the prefilled abbreviation should be selected so typing replaces it"
+ );
+ });
+ });
+ }
+
+ #[gpui::test]
+ async fn test_up_and_down_cycle_abbreviation_history(cx: &mut TestAppContext) {
+ let (editor, mut fake_servers, cx) = setup("hello
", cx).await;
+ let fake_server = fake_servers.next().await.unwrap();
+ cx.executor().run_until_parked();
+ cx.update(|_, cx| {
+ cx.bind_keys([
+ gpui::KeyBinding::new("up", crate::MoveUp, Some("Editor")),
+ gpui::KeyBinding::new("down", crate::MoveDown, Some("Editor")),
+ ]);
+ });
+ let mut requests = fake_server.set_request_handler::(
+ |params, _| async move { Ok(Some(format!("<{0}>hello{0}>", params.abbreviation))) },
+ );
+
+ for abbreviation in ["em", "strong", "span"] {
+ let hello = editor.update(cx, |editor, cx| {
+ let text = editor.text(cx);
+ let start = text.find("hello").expect("hello should still be there") as u32;
+ Point::new(0, start)..Point::new(0, start + "hello".len() as u32)
+ });
+ select_and_wrap(&editor, [hello], cx);
+ confirm_abbreviation(&editor, abbreviation, cx);
+ requests.next().await.unwrap();
+ cx.executor().run_until_parked();
+ }
+ editor.update(cx, |editor, cx| {
+ assert_eq!(
+ editor.text(cx),
+ "hello
"
+ );
+ });
+
+ select_and_wrap(&editor, [Point::new(0, 3)..Point::new(0, 8)], cx);
+ assert_eq!(input_text(&editor, cx), "span", "newest entry is prefilled");
+
+ cx.simulate_keystrokes("up");
+ assert_eq!(
+ input_text(&editor, cx),
+ "strong",
+ "up moves to the older entry"
+ );
+ cx.simulate_keystrokes("up");
+ assert_eq!(input_text(&editor, cx), "em");
+ cx.simulate_keystrokes("up");
+ assert_eq!(
+ input_text(&editor, cx),
+ "em",
+ "up stops at the oldest entry"
+ );
+
+ cx.simulate_keystrokes("down down down");
+ assert_eq!(
+ input_text(&editor, cx),
+ "",
+ "down past the newest entry returns to the empty draft"
+ );
+ cx.simulate_keystrokes("down");
+ assert_eq!(input_text(&editor, cx), "", "down stops at the draft");
+
+ editor.update_in(cx, |editor, window, cx| {
+ let input = pending_input(editor);
+ input.update(cx, |input, cx| input.set_text("div.wip", window, cx));
+ });
+ cx.executor().run_until_parked();
+ cx.simulate_keystrokes("up");
+ assert_eq!(
+ input_text(&editor, cx),
+ "span",
+ "up from a draft starts at the newest entry"
+ );
+ cx.simulate_keystrokes("down");
+ assert_eq!(
+ input_text(&editor, cx),
+ "div.wip",
+ "down restores the draft that was being typed"
+ );
+
+ editor.update(cx, |editor, cx| {
+ assert!(
+ editor.pending_inline_input.is_some(),
+ "cycling history must not dismiss the input"
+ );
+ assert_eq!(
+ editor.text(cx),
+ "hello
"
+ );
+ });
+ }
+
+ #[gpui::test]
+ async fn test_wrap_individual_lines(cx: &mut TestAppContext) {
+ let (editor, mut fake_servers, cx) =
+ setup("\n About\n News\n Products\n
", cx).await;
+ let fake_server = fake_servers.next().await.unwrap();
+ cx.executor().run_until_parked();
+
+ select_and_wrap(&editor, [Point::new(1, 4)..Point::new(3, 12)], cx);
+
+ let mut requests = fake_server.set_request_handler::(
+ |params, _| async move {
+ assert_eq!(params.abbreviation, "ul>li*");
+ assert_eq!(
+ params.options.text,
+ Some(vec![
+ "About".to_string(),
+ "News".to_string(),
+ "Products".to_string(),
+ ]),
+ "each selected line should be sent separately so `*` repeats per line"
+ );
+ assert_eq!(
+ params.options.options,
+ EmmetOutputOptions {
+ indent: " ".to_string(),
+ base_indent: " ".to_string(),
+ inline_break: Some(1),
+ ..EmmetOutputOptions::default()
+ },
+ "multi-line wraps should break inline elements per line \
+ and carry the buffer's indentation to the server"
+ );
+ Ok(Some(format_expansion(
+ "\n\tAbout \n\tNews \n\tProducts \n ",
+ ¶ms.options.options,
+ )))
+ },
+ );
+
+ confirm_abbreviation(&editor, "ul>li*", cx);
+ requests.next().await.unwrap();
+ cx.executor().run_until_parked();
+
+ editor.update(cx, |editor, cx| {
+ assert_eq!(
+ editor.text(cx),
+ "\n
\n About \n News \n Products \n \n
"
+ );
+ });
+ }
+
+ #[gpui::test]
+ async fn test_trim_filter_strips_list_markers(cx: &mut TestAppContext) {
+ let (editor, mut fake_servers, cx) = setup(" * one\n 2. two\n", cx).await;
+ let fake_server = fake_servers.next().await.unwrap();
+ cx.executor().run_until_parked();
+
+ select_and_wrap(&editor, [Point::new(0, 0)..Point::new(1, 8)], cx);
+
+ let mut requests = fake_server.set_request_handler::(
+ |params, _| async move {
+ assert_eq!(
+ params.abbreviation, "ul>li*",
+ "the `|t` filter should be stripped before querying the server"
+ );
+ assert_eq!(
+ params.options.text,
+ Some(vec!["one".to_string(), "two".to_string()]),
+ "list markers should be removed from the wrapped lines"
+ );
+ Ok(Some(format_expansion(
+ "",
+ ¶ms.options.options,
+ )))
+ },
+ );
+
+ confirm_abbreviation(&editor, "ul>li*|t", cx);
+ requests.next().await.unwrap();
+ cx.executor().run_until_parked();
+
+ editor.update(cx, |editor, cx| {
+ assert_eq!(
+ editor.text(cx),
+ " \n"
+ );
+ });
+ }
+
+ #[gpui::test]
+ async fn test_partial_tag_selection_expands_to_element(cx: &mut TestAppContext) {
+ let (editor, mut fake_servers, cx) = setup("hello
", cx).await;
+ let fake_server = fake_servers.next().await.unwrap();
+ cx.executor().run_until_parked();
+
+ select_and_wrap(&editor, [Point::new(0, 1)..Point::new(0, 6)], cx);
+
+ let mut requests = fake_server.set_request_handler::(
+ |params, _| async move {
+ assert_eq!(params.abbreviation, "div");
+ assert_eq!(
+ params.options.text,
+ Some(vec!["hello
".to_string()]),
+ "a selection endpoint inside a tag should expand to the whole element"
+ );
+ Ok(Some("".to_string()))
+ },
+ );
+
+ confirm_abbreviation(&editor, "div", cx);
+ requests.next().await.unwrap();
+ cx.executor().run_until_parked();
+
+ editor.update(cx, |editor, cx| {
+ assert_eq!(editor.text(cx), "");
+ });
+ }
+
+ #[gpui::test]
+ async fn test_dollar_signs_in_wrapped_text_are_escaped(cx: &mut TestAppContext) {
+ let (editor, mut fake_servers, cx) = setup("costs $100
", cx).await;
+ let fake_server = fake_servers.next().await.unwrap();
+ cx.executor().run_until_parked();
+
+ select_and_wrap(&editor, [Point::new(0, 3)..Point::new(0, 13)], cx);
+
+ let mut requests = fake_server.set_request_handler::(
+ |params, _| async move {
+ assert_eq!(
+ params.options.text,
+ Some(vec!["costs \\$100".to_string()]),
+ "dollar signs should be escaped so snippet insertion cannot eat them"
+ );
+ Ok(Some("costs \\$100
".to_string()))
+ },
+ );
+
+ confirm_abbreviation(&editor, "div", cx);
+ requests.next().await.unwrap();
+ cx.executor().run_until_parked();
+
+ editor.update(cx, |editor, cx| {
+ assert_eq!(
+ editor.text(cx),
+ "
costs $100
",
+ "the escaped dollar sign should round-trip back to a literal"
+ );
+ });
+ }
+
+ #[gpui::test]
+ async fn test_comment_and_bem_filters_are_forwarded(cx: &mut TestAppContext) {
+ let (editor, mut fake_servers, cx) = setup("hello
", cx).await;
+ let fake_server = fake_servers.next().await.unwrap();
+ cx.executor().run_until_parked();
+
+ select_and_wrap(&editor, [Point::new(0, 3)..Point::new(0, 8)], cx);
+
+ let mut requests = fake_server.set_request_handler::(
+ |params, _| async move {
+ assert_eq!(
+ params.abbreviation, "div#page",
+ "filters should be stripped from the abbreviation"
+ );
+ assert_eq!(
+ params.options.options,
+ EmmetOutputOptions {
+ indent: " ".to_string(),
+ base_indent: String::new(),
+ comment_enabled: Some(true),
+ bem_enabled: Some(true),
+ inline_break: None,
+ },
+ "the |c and |bem filters should map to emmet output options"
+ );
+ Ok(Some(
+ "hello
\n".to_string(),
+ ))
+ },
+ );
+
+ confirm_abbreviation(&editor, "div#page|c|bem", cx);
+ requests.next().await.unwrap();
+ cx.executor().run_until_parked();
+
+ editor.update(cx, |editor, cx| {
+ assert_eq!(
+ editor.text(cx),
+ "
hello
\n"
+ );
+ });
+ }
+
+ #[gpui::test]
+ async fn test_wrap_is_unavailable_in_stylesheets(cx: &mut TestAppContext) {
+ init_test(cx, |_| {});
+
+ let fs = FakeFs::new(cx.executor());
+ fs.insert_file(path!("/file.css"), ".a { color: red; }".into())
+ .await;
+
+ let project = Project::test(fs, [path!("/file.css").as_ref()], cx).await;
+ let language_registry = project.read_with(cx, |project, _| project.languages().clone());
+ language_registry.add(Arc::new(Language::new(
+ LanguageConfig {
+ name: "CSS".into(),
+ matcher: LanguageMatcher {
+ path_suffixes: vec!["css".into()],
+ ..LanguageMatcher::default()
+ }
+ .into(),
+ ..LanguageConfig::default()
+ },
+ None,
+ )));
+ let mut fake_servers = language_registry.register_fake_lsp(
+ "CSS",
+ FakeLspAdapter {
+ name: "emmet-language-server",
+ ..FakeLspAdapter::default()
+ },
+ );
+
+ let buffer = project
+ .update(cx, |project, cx| {
+ project.open_local_buffer(path!("/file.css"), cx)
+ })
+ .await
+ .unwrap();
+ let multi_buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx));
+ let (editor, cx) = cx.add_window_view(|window, cx| {
+ build_editor_with_project(project, multi_buffer, window, cx)
+ });
+ fake_servers.next().await.unwrap();
+ cx.executor().run_until_parked();
+
+ select_and_wrap(&editor, [Point::new(0, 0)..Point::new(0, 18)], cx);
+
+ editor.update(cx, |editor, cx| {
+ assert!(
+ editor.pending_inline_input.is_none(),
+ "wrapping is a markup action and should refuse stylesheets"
+ );
+ assert_eq!(editor.text(cx), ".a { color: red; }");
+ });
+ }
+
+ #[gpui::test]
+ async fn test_interleaved_overlapping_selections_are_merged(cx: &mut TestAppContext) {
+ let (editor, mut fake_servers, cx) = setup("", cx).await;
+ let fake_server = fake_servers.next().await.unwrap();
+ cx.executor().run_until_parked();
+
+ select_and_wrap(
+ &editor,
+ [
+ Point::new(0, 8)..Point::new(0, 9),
+ Point::new(0, 16)..Point::new(0, 17),
+ Point::new(0, 22)..Point::new(0, 22),
+ ],
+ cx,
+ );
+
+ let mut requests = fake_server.set_request_handler::(
+ |params, _| async move {
+ assert_eq!(params.abbreviation, "section");
+ assert_eq!(
+ params.options.text,
+ Some(vec!["".to_string()]),
+ "the element expanded from the trailing caret should be merged \
+ with the earlier selections into a single wrap target"
+ );
+ Ok(Some(
+ "".to_string(),
+ ))
+ },
+ );
+
+ confirm_abbreviation(&editor, "section", cx);
+ requests.next().await.unwrap();
+ cx.executor().run_until_parked();
+
+ editor.update(cx, |editor, cx| {
+ assert_eq!(
+ editor.text(cx),
+ ""
+ );
+ assert!(editor.pending_inline_input.is_none());
+ });
+ }
+
+ #[gpui::test]
+ async fn test_enter_confirms_when_parent_editor_is_focused(cx: &mut TestAppContext) {
+ let (editor, mut fake_servers, cx) = setup("hello
", cx).await;
+ let fake_server = fake_servers.next().await.unwrap();
+ cx.executor().run_until_parked();
+
+ select_and_wrap(&editor, [Point::new(0, 3)..Point::new(0, 8)], cx);
+
+ let mut requests = fake_server.set_request_handler::(
+ |params, _| async move {
+ assert_eq!(params.abbreviation, "div.wrap");
+ Ok(Some("hello
".to_string()))
+ },
+ );
+
+ editor.update_in(cx, |editor, window, cx| {
+ let input = pending_input(editor);
+ input.update(cx, |input, cx| input.set_text("div.wrap", window, cx));
+ });
+ cx.update(|_, cx| {
+ cx.bind_keys([
+ gpui::KeyBinding::new(
+ "enter",
+ crate::actions::Newline,
+ Some("Editor && mode == full"),
+ ),
+ gpui::KeyBinding::new("enter", menu::Confirm, Some("Editor && inline_input")),
+ ]);
+ });
+ editor.update_in(cx, |editor, window, cx| {
+ let focus_handle = editor.focus_handle(cx);
+ window.focus(&focus_handle, cx);
+ });
+ cx.simulate_keystrokes("enter");
+ requests.next().await.unwrap();
+ cx.executor().run_until_parked();
+
+ editor.update(cx, |editor, cx| {
+ assert_eq!(
+ editor.text(cx),
+ "
hello
",
+ "enter should confirm the wrap instead of inserting a newline"
+ );
+ assert!(editor.pending_inline_input.is_none());
+ });
+ }
+
+ #[gpui::test]
+ async fn test_wrap_in_multibuffer_excerpts(cx: &mut TestAppContext) {
+ init_test(cx, |_| {});
+
+ let fs = FakeFs::new(cx.executor());
+ fs.insert_tree(
+ path!("/root"),
+ serde_json::json!({
+ "a.html": "one
",
+ "b.html": "two
",
+ }),
+ )
+ .await;
+
+ let project = Project::test(fs, [path!("/root").as_ref()], cx).await;
+ let mut fake_servers = register_html_with_emmet(&project, cx);
+
+ let buffer_a = project
+ .update(cx, |project, cx| {
+ project.open_local_buffer(path!("/root/a.html"), cx)
+ })
+ .await
+ .unwrap();
+ let buffer_b = project
+ .update(cx, |project, cx| {
+ project.open_local_buffer(path!("/root/b.html"), cx)
+ })
+ .await
+ .unwrap();
+ let multi_buffer = cx.new(|cx| {
+ let mut multi_buffer = MultiBuffer::new(Capability::ReadWrite);
+ multi_buffer.set_excerpts_for_path(
+ PathKey::sorted(0),
+ buffer_a.clone(),
+ [Point::new(0, 0)..Point::new(0, 10)],
+ 0,
+ cx,
+ );
+ multi_buffer.set_excerpts_for_path(
+ PathKey::sorted(1),
+ buffer_b.clone(),
+ [Point::new(0, 0)..Point::new(0, 10)],
+ 0,
+ cx,
+ );
+ multi_buffer
+ });
+ let (editor, cx) = cx.add_window_view(|window, cx| {
+ build_editor_with_project(project, multi_buffer, window, cx)
+ });
+ let fake_server = fake_servers.next().await.unwrap();
+ cx.executor().run_until_parked();
+
+ select_and_wrap(
+ &editor,
+ [
+ Point::new(0, 3)..Point::new(0, 6),
+ Point::new(1, 3)..Point::new(1, 6),
+ ],
+ cx,
+ );
+
+ let mut requests = fake_server.set_request_handler::(
+ |params, _| async move {
+ assert_eq!(params.abbreviation, "div");
+ let wrapped = match params.options.text.as_deref() {
+ Some([line]) if line == "one" => "one
",
+ Some([line]) if line == "two" => "two
",
+ other => panic!("unexpected wrap text: {other:?}"),
+ };
+ Ok(Some(wrapped.to_string()))
+ },
+ );
+
+ confirm_abbreviation(&editor, "div", cx);
+ requests.next().await.unwrap();
+ requests.next().await.unwrap();
+ cx.executor().run_until_parked();
+
+ editor.update(cx, |editor, cx| {
+ assert_eq!(
+ editor.text(cx),
+ "
one
\n
two
",
+ "each excerpt should be wrapped in its own buffer"
+ );
+ assert!(editor.pending_inline_input.is_none());
+ });
+ cx.update(|_, cx| {
+ assert_eq!(buffer_a.read(cx).text(), "
one
");
+ assert_eq!(buffer_b.read(cx).text(), "
two
");
+ });
+ }
+
+ fn select_and_wrap(
+ editor: &Entity,
+ ranges: impl IntoIterator- >,
+ cx: &mut VisualTestContext,
+ ) {
+ editor.update_in(cx, |editor, window, cx| {
+ editor.change_selections(SelectionEffects::default(), window, cx, |s| {
+ s.select_ranges(ranges)
+ });
+ wrap_with_abbreviation(editor, &WrapWithAbbreviation, window, cx);
+ });
+ }
+
+ fn confirm_abbreviation(
+ editor: &Entity
,
+ abbreviation: &str,
+ cx: &mut VisualTestContext,
+ ) {
+ editor.update_in(cx, |editor, window, cx| {
+ let input = pending_input(editor);
+ input.update(cx, |input, cx| input.set_text(abbreviation, window, cx));
+ editor.confirm_inline_input(window, cx);
+ });
+ }
+
+ fn format_expansion(tab_indented: &str, options: &EmmetOutputOptions) -> String {
+ tab_indented
+ .split('\n')
+ .enumerate()
+ .map(|(ix, line)| {
+ let levels = line.chars().take_while(|ch| *ch == '\t').count();
+ let base = if ix == 0 { "" } else { &options.base_indent };
+ format!("{base}{}{}", options.indent.repeat(levels), &line[levels..])
+ })
+ .collect::>()
+ .join("\n")
+ }
+
+ fn input_text(editor: &Entity, cx: &mut VisualTestContext) -> String {
+ editor.update(cx, |editor, cx| pending_input(editor).read(cx).text(cx))
+ }
+
+ fn pending_input(editor: &Editor) -> Entity {
+ editor
+ .pending_inline_input
+ .as_ref()
+ .expect("emmet wrap input should be pending")
+ .editor
+ .clone()
+ }
+
+ async fn setup<'a>(
+ text: &str,
+ cx: &'a mut TestAppContext,
+ ) -> (
+ Entity,
+ futures::channel::mpsc::UnboundedReceiver,
+ &'a mut VisualTestContext,
+ ) {
+ init_test(cx, |_| {});
+
+ let fs = FakeFs::new(cx.executor());
+ fs.insert_file(path!("/file.html"), text.into()).await;
+
+ let project = Project::test(fs, [path!("/file.html").as_ref()], cx).await;
+ let fake_servers = register_html_with_emmet(&project, cx);
+
+ let buffer = project
+ .update(cx, |project, cx| {
+ project.open_local_buffer(path!("/file.html"), cx)
+ })
+ .await
+ .unwrap();
+ let multi_buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx));
+ let (editor, cx) = cx.add_window_view(|window, cx| {
+ build_editor_with_project(project, multi_buffer, window, cx)
+ });
+ (editor, fake_servers, cx)
+ }
+
+ fn register_html_with_emmet(
+ project: &Entity,
+ cx: &mut TestAppContext,
+ ) -> futures::channel::mpsc::UnboundedReceiver {
+ let language_registry = project.read_with(cx, |project, _| project.languages().clone());
+ language_registry.add(Arc::new(Language::new(
+ LanguageConfig {
+ name: "HTML".into(),
+ matcher: LanguageMatcher {
+ path_suffixes: vec!["html".into()],
+ ..LanguageMatcher::default()
+ }
+ .into(),
+ ..LanguageConfig::default()
+ },
+ Some(tree_sitter_html::LANGUAGE.into()),
+ )));
+ language_registry.register_fake_lsp(
+ "HTML",
+ FakeLspAdapter {
+ name: "emmet-language-server",
+ ..FakeLspAdapter::default()
+ },
+ )
+ }
+}
diff --git a/crates/editor/src/inline_input.rs b/crates/editor/src/inline_input.rs
new file mode 100644
index 0000000000..5cce637c25
--- /dev/null
+++ b/crates/editor/src/inline_input.rs
@@ -0,0 +1,370 @@
+use gpui::StyledText;
+use language::HighlightId;
+
+use super::*;
+
+pub struct InlineInputState {
+ pub editor: Entity,
+ block_id: CustomBlockId,
+ position: Anchor,
+ confirm_task: Option>,
+ preview_task: Option>,
+ pub(crate) preview: Option,
+ preview_language: Option>,
+ block_height: u32,
+ history: Vec,
+ history_ix: Option,
+ draft: String,
+ _subscription: Subscription,
+ on_confirm:
+ Rc) -> Option>>,
+}
+
+impl InlineInputState {
+ pub(crate) fn take_confirm_task(&mut self) -> Option> {
+ self.confirm_task.take()
+ }
+}
+
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub(crate) enum InlineInputPreview {
+ Text(String),
+ Error(String),
+}
+
+#[derive(Clone, Copy)]
+pub(crate) enum InlineInputHistoryDirection {
+ Older,
+ Newer,
+}
+
+const INLINE_INPUT_PREVIEW_MIN_LINES: usize = 8;
+
+impl InlineInputPreview {
+ fn display_text(&self, max_lines: usize) -> (String, u32) {
+ let text = match self {
+ InlineInputPreview::Text(text) => text,
+ InlineInputPreview::Error(message) => message,
+ };
+ let line_count = text.lines().count().max(1);
+ if line_count <= max_lines {
+ return (text.to_string(), line_count as u32);
+ }
+ let hidden_lines = line_count - max_lines;
+ let noun = if hidden_lines == 1 { "line" } else { "lines" };
+ let shown = text.lines().take(max_lines).collect::>().join("\n");
+ (
+ format!("{shown}\n… +{hidden_lines} more {noun}"),
+ max_lines as u32 + 1,
+ )
+ }
+
+ fn is_error(&self) -> bool {
+ matches!(self, InlineInputPreview::Error(_))
+ }
+}
+
+struct RenderedPreview {
+ text: SharedString,
+ highlights: Vec<(Range, HighlightId)>,
+ height_in_lines: u32,
+ is_error: bool,
+}
+
+impl RenderedPreview {
+ fn new(
+ preview: &InlineInputPreview,
+ language: Option<&Arc>,
+ max_lines: usize,
+ ) -> Self {
+ let (text, height_in_lines) = preview.display_text(max_lines);
+ let highlights = match (preview, language) {
+ (InlineInputPreview::Text(_), Some(language)) => {
+ language.highlight_text(&Rope::from(text.as_str()), 0..text.len())
+ }
+ _ => Vec::new(),
+ };
+ Self {
+ text: SharedString::from(text),
+ highlights,
+ height_in_lines,
+ is_error: preview.is_error(),
+ }
+ }
+}
+
+impl Editor {
+ pub fn pending_inline_input(&self) -> Option<&InlineInputState> {
+ self.pending_inline_input.as_ref()
+ }
+
+ pub fn confirm_inline_input(&mut self, window: &mut Window, cx: &mut Context) {
+ let Some(state) = self.pending_inline_input.as_ref() else {
+ return;
+ };
+ let text = state.editor.read(cx).text(cx);
+ let on_confirm = state.on_confirm.clone();
+ if let Some(task) = on_confirm(self, text, window, cx)
+ && let Some(state) = self.pending_inline_input.as_mut()
+ {
+ state.confirm_task = Some(task);
+ }
+ }
+
+ pub(crate) fn show_inline_input(
+ &mut self,
+ placeholder: &str,
+ position: Anchor,
+ preview_language: Option>,
+ history: Vec,
+ on_confirm: impl Fn(&mut Editor, String, &mut Window, &mut Context) -> Option>
+ + 'static,
+ on_change: impl Fn(&mut Editor, String, &mut Window, &mut Context) -> Option>
+ + 'static,
+ window: &mut Window,
+ cx: &mut Context,
+ ) {
+ self.take_inline_input(window, cx);
+
+ let input = cx.new(|cx| {
+ let mut input = Editor::single_line(window, cx);
+ input.set_placeholder_text(placeholder, window, cx);
+ if let Some(newest) = history.first() {
+ input.set_text(newest.clone(), window, cx);
+ input.select_all(&SelectAll, window, cx);
+ }
+ input
+ });
+ let subscription = cx.subscribe_in(
+ &input,
+ window,
+ move |editor, input, event: &EditorEvent, window, cx| match event {
+ EditorEvent::Focused => cx.emit(EditorEvent::FocusedIn),
+ EditorEvent::BufferEdited => {
+ let Some(state) = editor.pending_inline_input.as_mut() else {
+ return;
+ };
+ let text = input.read(cx).text(cx);
+ if state.history_ix.is_some_and(|ix| {
+ state.history.get(ix).map(SharedString::as_ref) != Some(text.as_str())
+ }) {
+ state.history_ix = None;
+ }
+ let task = on_change(editor, text, window, cx);
+ if let Some(state) = editor.pending_inline_input.as_mut() {
+ state.preview_task = task;
+ }
+ }
+ _ => {}
+ },
+ );
+ let block_ids = self.insert_blocks(
+ [BlockProperties {
+ style: BlockStyle::Flex,
+ placement: BlockPlacement::Below(position),
+ height: Some(1),
+ render: render_inline_input_block(input.clone(), None),
+ priority: 0,
+ }],
+ Some(reveal_block_below(
+ position,
+ &self.buffer().read(cx).snapshot(cx),
+ )),
+ cx,
+ );
+ let Some(&block_id) = block_ids.first() else {
+ return;
+ };
+
+ let input_focus_handle = input.focus_handle(cx);
+ window.focus(&input_focus_handle, cx);
+ self.pending_inline_input = Some(InlineInputState {
+ editor: input,
+ block_id,
+ position,
+ confirm_task: None,
+ preview_task: None,
+ preview: None,
+ preview_language,
+ block_height: 1,
+ history_ix: (!history.is_empty()).then_some(0),
+ history,
+ draft: String::new(),
+ _subscription: subscription,
+ on_confirm: Rc::new(on_confirm),
+ });
+ }
+
+ pub(crate) fn set_inline_input_preview(
+ &mut self,
+ preview: Option,
+ cx: &mut Context,
+ ) {
+ let max_lines = self.inline_input_preview_max_lines();
+ let Some(state) = self.pending_inline_input.as_mut() else {
+ return;
+ };
+ let block_id = state.block_id;
+ let position = state.position;
+ let input = state.editor.clone();
+ let rendered = preview.as_ref().map(|preview| {
+ RenderedPreview::new(preview, state.preview_language.as_ref(), max_lines)
+ });
+ let height = 1 + rendered
+ .as_ref()
+ .map_or(0, |rendered| rendered.height_in_lines);
+ state.preview = preview;
+ let resized = height != state.block_height;
+ state.block_height = height;
+ let autoscroll =
+ resized.then(|| reveal_block_below(position, &self.buffer().read(cx).snapshot(cx)));
+ self.replace_blocks(
+ HashMap::from_iter([(block_id, render_inline_input_block(input, rendered))]),
+ None,
+ cx,
+ );
+ self.resize_blocks(HashMap::from_iter([(block_id, height)]), autoscroll, cx);
+ }
+
+ pub(crate) fn cycle_inline_input_history(
+ &mut self,
+ direction: InlineInputHistoryDirection,
+ window: &mut Window,
+ cx: &mut Context,
+ ) -> bool {
+ let Some(state) = self.pending_inline_input.as_mut() else {
+ return false;
+ };
+ let next_ix = match (direction, state.history_ix) {
+ (InlineInputHistoryDirection::Older, None) => {
+ if state.history.is_empty() {
+ return true;
+ }
+ state.draft = state.editor.read(cx).text(cx);
+ Some(0)
+ }
+ (InlineInputHistoryDirection::Older, Some(ix)) => {
+ if ix + 1 >= state.history.len() {
+ return true;
+ }
+ Some(ix + 1)
+ }
+ (InlineInputHistoryDirection::Newer, None) => return true,
+ (InlineInputHistoryDirection::Newer, Some(0)) => None,
+ (InlineInputHistoryDirection::Newer, Some(ix)) => Some(ix - 1),
+ };
+ state.history_ix = next_ix;
+ let text = match next_ix {
+ Some(ix) => state.history.get(ix).cloned().unwrap_or_default(),
+ None => SharedString::from(mem::take(&mut state.draft)),
+ };
+ let input = state.editor.clone();
+ input.update(cx, |input, cx| {
+ input.set_text(text, window, cx);
+ input.select_all(&SelectAll, window, cx);
+ });
+ true
+ }
+
+ pub(crate) fn take_inline_input(
+ &mut self,
+ window: &mut Window,
+ cx: &mut Context,
+ ) -> Option {
+ let state = self.pending_inline_input.take()?;
+ if state.editor.focus_handle(cx).is_focused(window) {
+ window.focus(&self.focus_handle, cx);
+ }
+ self.remove_blocks(
+ HashSet::from_iter([state.block_id]),
+ Some(Autoscroll::fit()),
+ cx,
+ );
+ Some(state)
+ }
+
+ fn inline_input_preview_max_lines(&self) -> usize {
+ let half_viewport = (self.visible_line_count().unwrap_or(0.) / 2.).floor() as usize;
+ half_viewport.max(INLINE_INPUT_PREVIEW_MIN_LINES)
+ }
+}
+
+fn reveal_block_below(position: Anchor, snapshot: &MultiBufferSnapshot) -> Autoscroll {
+ let row_below = position.to_point(snapshot).row + 1;
+ if row_below > snapshot.max_point().row {
+ return Autoscroll::center().for_anchor(position);
+ }
+ Autoscroll::fit().for_anchor(snapshot.anchor_before(Point::new(row_below, 0)))
+}
+
+fn render_inline_input_block(
+ input: Entity,
+ preview: Option,
+) -> RenderBlock {
+ Arc::new(move |cx: &mut BlockContext| {
+ v_flex()
+ .block_mouse_except_scroll()
+ .w_full()
+ .bg(cx.theme().colors().elevated_surface_background)
+ .pl(cx.anchor_x)
+ .child(EditorElement::new(
+ &input,
+ EditorStyle {
+ background: cx.theme().system().transparent,
+ local_player: cx.editor_style.local_player,
+ text: cx.editor_style.text.clone(),
+ scrollbar_width: cx.editor_style.scrollbar_width,
+ syntax: cx.editor_style.syntax.clone(),
+ status: cx.editor_style.status.clone(),
+ ..EditorStyle::default()
+ },
+ ))
+ .when_some(preview.as_ref(), |this, preview| {
+ let mut text_style = cx.editor_style.text.clone();
+ text_style.color = if preview.is_error {
+ cx.theme().status().error
+ } else {
+ cx.theme().colors().text_muted
+ };
+ let syntax = cx.theme().syntax();
+ let highlights = preview
+ .highlights
+ .iter()
+ .filter_map(|(range, highlight_id)| {
+ Some((range.clone(), *syntax.get(*highlight_id)?))
+ });
+ this.child(
+ StyledText::new(preview.text.clone())
+ .with_default_highlights(&text_style, highlights),
+ )
+ })
+ .into_any_element()
+ })
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn test_preview_truncation_reports_hidden_lines() {
+ let text = (1..=12)
+ .map(|ix| format!("line{ix}"))
+ .collect::>()
+ .join("\n");
+ let preview = InlineInputPreview::Text(text.clone());
+ let (shown, height) = preview.display_text(8);
+ assert_eq!(height, 9);
+ assert_eq!(
+ shown,
+ format!(
+ "{}\n… +4 more lines",
+ text.lines().take(8).collect::>().join("\n")
+ )
+ );
+ let (shown, height) = preview.display_text(11);
+ assert_eq!(height, 12);
+ assert_eq!(shown.lines().last(), Some("… +1 more line"));
+ assert_eq!(preview.display_text(12), (text, 12));
+ }
+}
diff --git a/crates/editor/src/lsp_ext.rs b/crates/editor/src/lsp_ext.rs
index e70f1b1202..8831728674 100644
--- a/crates/editor/src/lsp_ext.rs
+++ b/crates/editor/src/lsp_ext.rs
@@ -151,6 +151,7 @@ pub fn lsp_tasks(
GetLspRunnables {
buffer_id,
position: for_position,
+ server_id,
},
cx,
)
diff --git a/crates/editor/src/navigation.rs b/crates/editor/src/navigation.rs
index 3766811b76..1fc78b3198 100644
--- a/crates/editor/src/navigation.rs
+++ b/crates/editor/src/navigation.rs
@@ -45,6 +45,9 @@ impl Editor {
if self.take_rename(true, window, cx).is_some() {
return;
}
+ if self.cycle_inline_input_history(InlineInputHistoryDirection::Older, window, cx) {
+ return;
+ }
if self.mode.is_single_line() {
cx.propagate();
@@ -262,6 +265,9 @@ impl Editor {
if self.take_rename(true, window, cx).is_some() {
return;
}
+ if self.cycle_inline_input_history(InlineInputHistoryDirection::Newer, window, cx) {
+ return;
+ }
if self.mode.is_single_line() {
cx.propagate();
diff --git a/crates/editor/src/rust_analyzer_ext.rs b/crates/editor/src/rust_analyzer_ext.rs
index ab59586c3a..b65a5082e9 100644
--- a/crates/editor/src/rust_analyzer_ext.rs
+++ b/crates/editor/src/rust_analyzer_ext.rs
@@ -89,6 +89,7 @@ pub fn go_to_parent_module(
project_id,
buffer_id: buffer_id.to_proto(),
position: Some(serialize_anchor(&trigger_anchor)),
+ server_id: server_to_query.to_proto(),
};
let response = client
.request(request)
@@ -112,7 +113,10 @@ pub fn go_to_parent_module(
project.request_lsp(
buffer,
project::LanguageServerToQuery::Other(server_to_query),
- project::lsp_store::lsp_ext_command::GoToParentModule { position },
+ project::lsp_store::lsp_ext_command::GoToParentModule {
+ position,
+ server_id: server_to_query,
+ },
cx,
)
})
@@ -169,6 +173,7 @@ pub fn expand_macro_recursively(
project_id,
buffer_id: buffer_id.to_proto(),
position: Some(serialize_anchor(&trigger_anchor)),
+ server_id: server_to_query.to_proto(),
};
let response = client
.request(request)
@@ -186,7 +191,10 @@ pub fn expand_macro_recursively(
project.request_lsp(
buffer,
project::LanguageServerToQuery::Other(server_to_query),
- ExpandMacro { position },
+ ExpandMacro {
+ position,
+ server_id: server_to_query,
+ },
cx,
)
})
@@ -258,6 +266,7 @@ pub fn open_docs(editor: &mut Editor, _: &OpenDocs, window: &mut Window, cx: &mu
project_id,
buffer_id: buffer_id.to_proto(),
position: Some(serialize_anchor(&trigger_anchor)),
+ server_id: server_to_query.to_proto(),
};
let response = client
.request(request)
@@ -275,7 +284,10 @@ pub fn open_docs(editor: &mut Editor, _: &OpenDocs, window: &mut Window, cx: &mu
project.request_lsp(
buffer,
project::LanguageServerToQuery::Other(server_to_query),
- project::lsp_store::lsp_ext_command::OpenDocs { position },
+ project::lsp_store::lsp_ext_command::OpenDocs {
+ position,
+ server_id: server_to_query,
+ },
cx,
)
})
diff --git a/crates/editor/src/selection.rs b/crates/editor/src/selection.rs
index 11cd7e3826..4c1908a639 100644
--- a/crates/editor/src/selection.rs
+++ b/crates/editor/src/selection.rs
@@ -1600,6 +1600,7 @@ impl Editor {
self.invalidate_autoclose_regions(&selection_anchors, buffer);
self.snippet_stack.invalidate(&selection_anchors, buffer);
self.take_rename(false, window, cx);
+ self.take_inline_input(window, cx);
let newest_selection = self.selections.newest_anchor();
let new_cursor_position = newest_selection.head();
diff --git a/crates/editor/src/test.rs b/crates/editor/src/test.rs
index e217398faa..cda783fbfb 100644
--- a/crates/editor/src/test.rs
+++ b/crates/editor/src/test.rs
@@ -3,6 +3,7 @@ pub mod editor_test_context;
use std::{rc::Rc, sync::LazyLock};
+pub use crate::emmet_ext::wrap_with_abbreviation;
pub use crate::rust_analyzer_ext::expand_macro_recursively;
use crate::{
DisplayPoint, Editor, EditorMode, FoldPlaceholder, MultiBuffer, SelectionEffects, Size,
diff --git a/crates/language/src/language_registry.rs b/crates/language/src/language_registry.rs
index d734876003..22561ee834 100644
--- a/crates/language/src/language_registry.rs
+++ b/crates/language/src/language_registry.rs
@@ -894,6 +894,20 @@ impl LanguageRegistry {
.unwrap_or_default()
}
+ pub fn lsp_adapter(
+ &self,
+ language_name: &LanguageName,
+ server_name: &LanguageServerName,
+ ) -> Option> {
+ self.state
+ .read()
+ .lsp_adapters
+ .get(language_name)?
+ .iter()
+ .find(|adapter| adapter.name() == *server_name)
+ .cloned()
+ }
+
pub fn all_lsp_adapters(&self) -> Vec> {
self.state
.read()
diff --git a/crates/project/src/lsp_command.rs b/crates/project/src/lsp_command.rs
index ddc74a4a32..a386162413 100644
--- a/crates/project/src/lsp_command.rs
+++ b/crates/project/src/lsp_command.rs
@@ -6,7 +6,7 @@ use crate::{
InlayHintLabel, InlayHintLabelPart, InlayHintLabelPartTooltip, InlayHintTooltip, Location,
LocationLink, LspAction, LspPullDiagnostics, MarkupContent, PrepareRenameResponse, ProjectPath,
ProjectTransaction, PulledDiagnostics, ResolveState,
- lsp_store::{LocalLspStore, LspDocumentLink, LspFoldingRange, LspStore},
+ lsp_store::{LanguageServerToQuery, LocalLspStore, LspDocumentLink, LspFoldingRange, LspStore},
};
use anyhow::{Context as _, Result};
use async_trait::async_trait;
@@ -99,6 +99,10 @@ pub trait LspCommand: 'static + Sized + Send + std::fmt::Debug {
None
}
+ fn language_server_to_query(&self) -> LanguageServerToQuery {
+ LanguageServerToQuery::FirstCapable
+ }
+
fn to_lsp_params_or_response(
&self,
path: &Path,
diff --git a/crates/project/src/lsp_store.rs b/crates/project/src/lsp_store.rs
index bd4d53ab54..266d398fd5 100644
--- a/crates/project/src/lsp_store.rs
+++ b/crates/project/src/lsp_store.rs
@@ -15,6 +15,7 @@ mod document_colors;
mod document_links;
mod document_symbols;
mod dynamic_registration;
+pub mod emmet_ext;
mod folding_ranges;
mod inlay_hints;
pub mod json_language_server_ext;
@@ -4605,6 +4606,8 @@ impl LspStore {
client.add_entity_request_handler(Self::handle_lsp_ext_run_flycheck);
client.add_entity_request_handler(Self::handle_lsp_ext_clear_flycheck);
client.add_entity_request_handler(Self::handle_lsp_command::);
+ client
+ .add_entity_request_handler(Self::handle_lsp_command::);
client.add_entity_request_handler(Self::handle_lsp_command::);
client.add_entity_request_handler(
Self::handle_lsp_command::,
@@ -5714,16 +5717,16 @@ impl LspStore {
Ok(LspParamsOrResponse::Params(lsp_params)) => lsp_params,
Ok(LspParamsOrResponse::Response(response)) => return Task::ready(Ok(response)),
Err(err) => {
- let message = format!(
- "{} via {} failed: {}",
+ let err = err.context(format!(
+ "{} via {} failed",
request.display_name(),
language_server.name(),
- err
- );
+ ));
+ let message = format!("{err:#}");
if should_log_lsp_request_failure(&message) {
log::warn!("{message}");
}
- return Task::ready(Err(anyhow!(message)));
+ return Task::ready(Err(err));
}
};
@@ -5775,16 +5778,16 @@ impl LspStore {
let result = lsp_request.await.into_response();
let response = result.map_err(|err| {
- let message = format!(
- "{} via {} failed: {}",
+ let err = err.context(format!(
+ "{} via {} failed",
request.display_name(),
language_server.name(),
- err
- );
+ ));
+ let message = format!("{err:#}");
if should_log_lsp_request_failure(&message) {
log::warn!("{message}");
}
- anyhow::anyhow!(message)
+ err
})?;
request
@@ -9168,6 +9171,13 @@ impl LspStore {
.unwrap_or_default()
}
+ pub fn language_server_ids_for_opened_buffer(
+ &self,
+ buffer_id: BufferId,
+ ) -> Option<&HashSet> {
+ self.as_local()?.buffers_opened_in_servers.get(&buffer_id)
+ }
+
pub fn language_server_for_local_buffer<'a>(
&'a self,
buffer: &'a Buffer,
@@ -9969,14 +9979,10 @@ impl LspStore {
cx.clone(),
)
.await?;
+ let server = request.language_server_to_query();
let response = this
.update(&mut cx, |this, cx| {
- this.request_lsp(
- buffer_handle.clone(),
- LanguageServerToQuery::FirstCapable,
- request,
- cx,
- )
+ this.request_lsp(buffer_handle.clone(), server, request, cx)
})
.await?;
this.update(&mut cx, |this, cx| {
diff --git a/crates/project/src/lsp_store/emmet_ext.rs b/crates/project/src/lsp_store/emmet_ext.rs
new file mode 100644
index 0000000000..93feeeb98c
--- /dev/null
+++ b/crates/project/src/lsp_store/emmet_ext.rs
@@ -0,0 +1,186 @@
+use std::{path::Path, sync::Arc};
+
+use anyhow::Result;
+use async_trait::async_trait;
+use gpui::{App, AsyncApp, Entity};
+use language::Buffer;
+use lsp::{AdapterServerCapabilities, LanguageServer, LanguageServerId};
+use rpc::proto::{self, PeerId};
+use serde::{Deserialize, Serialize};
+use text::BufferId;
+
+use crate::{
+ lsp_command::LspCommand,
+ lsp_store::{LanguageServerToQuery, LspStore},
+};
+
+pub const EMMET_SERVER_NAME: lsp::LanguageServerName =
+ lsp::LanguageServerName::new_static("emmet-language-server");
+
+pub enum LspExpandAbbreviation {}
+
+impl lsp::request::Request for LspExpandAbbreviation {
+ type Params = ExpandAbbreviationParams;
+ type Result = Option;
+ const METHOD: &'static str = "emmet/expandAbbreviation";
+}
+
+#[derive(Deserialize, Serialize, Debug)]
+#[serde(rename_all = "camelCase")]
+pub struct ExpandAbbreviationParams {
+ pub abbreviation: String,
+ pub language: String,
+ pub options: ExpandAbbreviationOptions,
+}
+
+#[derive(Deserialize, Serialize, Debug)]
+#[serde(rename_all = "camelCase")]
+pub struct ExpandAbbreviationOptions {
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub text: Option>,
+ pub options: EmmetOutputOptions,
+}
+
+#[derive(Deserialize, Serialize, Debug, Default, PartialEq, Eq)]
+pub struct EmmetOutputOptions {
+ #[serde(rename = "output.indent")]
+ pub indent: String,
+ #[serde(rename = "output.baseIndent")]
+ pub base_indent: String,
+ #[serde(rename = "comment.enabled", skip_serializing_if = "Option::is_none")]
+ pub comment_enabled: Option,
+ #[serde(rename = "bem.enabled", skip_serializing_if = "Option::is_none")]
+ pub bem_enabled: Option,
+ #[serde(rename = "output.inlineBreak", skip_serializing_if = "Option::is_none")]
+ pub inline_break: Option,
+}
+
+#[derive(Debug)]
+pub struct ExpandAbbreviation {
+ pub abbreviation: String,
+ pub text: Option>,
+ pub language: String,
+ pub server_id: LanguageServerId,
+ pub indent: String,
+ pub base_indent: String,
+ pub comment_filter: bool,
+ pub bem_filter: bool,
+}
+
+#[async_trait(?Send)]
+impl LspCommand for ExpandAbbreviation {
+ type Response = Option;
+ type LspRequest = LspExpandAbbreviation;
+ type ProtoRequest = proto::LspExtExpandAbbreviation;
+
+ fn display_name(&self) -> &str {
+ "Expand Emmet abbreviation"
+ }
+
+ fn check_capabilities(&self, _: AdapterServerCapabilities) -> bool {
+ true
+ }
+
+ fn language_server_to_query(&self) -> LanguageServerToQuery {
+ LanguageServerToQuery::Other(self.server_id)
+ }
+
+ fn to_lsp(
+ &self,
+ _: &Path,
+ _: &Buffer,
+ server: &Arc,
+ _: &App,
+ ) -> Result {
+ anyhow::ensure!(
+ server.name() == EMMET_SERVER_NAME,
+ "cannot expand Emmet abbreviations with the {} server",
+ server.name()
+ );
+ let multiline = self.text.as_ref().is_some_and(|text| text.len() > 1);
+ Ok(ExpandAbbreviationParams {
+ abbreviation: self.abbreviation.clone(),
+ language: self.language.clone(),
+ options: ExpandAbbreviationOptions {
+ text: self.text.clone(),
+ options: EmmetOutputOptions {
+ indent: self.indent.clone(),
+ base_indent: self.base_indent.clone(),
+ comment_enabled: self.comment_filter.then_some(true),
+ bem_enabled: self.bem_filter.then_some(true),
+ inline_break: multiline.then_some(1),
+ },
+ },
+ })
+ }
+
+ async fn response_from_lsp(
+ self,
+ message: Option,
+ _: Entity,
+ _: Entity,
+ _: LanguageServerId,
+ _: AsyncApp,
+ ) -> anyhow::Result> {
+ Ok(message.filter(|expansion| !expansion.is_empty()))
+ }
+
+ fn to_proto(&self, project_id: u64, buffer: &Buffer) -> proto::LspExtExpandAbbreviation {
+ proto::LspExtExpandAbbreviation {
+ project_id,
+ buffer_id: buffer.remote_id().into(),
+ abbreviation: self.abbreviation.clone(),
+ text: self.text.clone().unwrap_or_default(),
+ language: self.language.clone(),
+ server_id: self.server_id.to_proto(),
+ indent: self.indent.clone(),
+ base_indent: self.base_indent.clone(),
+ comment_filter: self.comment_filter,
+ bem_filter: self.bem_filter,
+ }
+ }
+
+ async fn from_proto(
+ message: Self::ProtoRequest,
+ _: Entity,
+ _: Entity,
+ _: AsyncApp,
+ ) -> anyhow::Result {
+ Ok(Self {
+ abbreviation: message.abbreviation,
+ text: (!message.text.is_empty()).then_some(message.text),
+ language: message.language,
+ server_id: LanguageServerId::from_proto(message.server_id),
+ indent: message.indent,
+ base_indent: message.base_indent,
+ comment_filter: message.comment_filter,
+ bem_filter: message.bem_filter,
+ })
+ }
+
+ fn response_to_proto(
+ response: Option,
+ _: &mut LspStore,
+ _: PeerId,
+ _: &clock::Global,
+ _: &mut App,
+ ) -> proto::LspExtExpandAbbreviationResponse {
+ proto::LspExtExpandAbbreviationResponse {
+ expansion: response,
+ }
+ }
+
+ async fn response_from_proto(
+ self,
+ message: proto::LspExtExpandAbbreviationResponse,
+ _: Entity,
+ _: Entity,
+ _: AsyncApp,
+ ) -> anyhow::Result> {
+ Ok(message.expansion.filter(|expansion| !expansion.is_empty()))
+ }
+
+ fn buffer_id_from_proto(message: &proto::LspExtExpandAbbreviation) -> Result {
+ BufferId::new(message.buffer_id)
+ }
+}
diff --git a/crates/project/src/lsp_store/lsp_ext_command.rs b/crates/project/src/lsp_store/lsp_ext_command.rs
index bb994492d0..6e6fd59399 100644
--- a/crates/project/src/lsp_store/lsp_ext_command.rs
+++ b/crates/project/src/lsp_store/lsp_ext_command.rs
@@ -5,7 +5,7 @@ use crate::{
location_link_to_proto, location_links_from_lsp, location_links_from_proto,
location_links_to_proto,
},
- lsp_store::LspStore,
+ lsp_store::{LanguageServerToQuery, LspStore},
make_lsp_text_document_position, make_text_document_identifier,
};
use anyhow::{Context as _, Result};
@@ -56,6 +56,7 @@ impl ExpandedMacro {
#[derive(Debug)]
pub struct ExpandMacro {
pub position: PointUtf16,
+ pub server_id: LanguageServerId,
}
#[async_trait(?Send)]
@@ -72,6 +73,10 @@ impl LspCommand for ExpandMacro {
true
}
+ fn language_server_to_query(&self) -> LanguageServerToQuery {
+ LanguageServerToQuery::Other(self.server_id)
+ }
+
fn to_lsp(
&self,
path: &Path,
@@ -108,6 +113,7 @@ impl LspCommand for ExpandMacro {
position: Some(language::proto::serialize_anchor(
&buffer.anchor_before(self.position),
)),
+ server_id: self.server_id.to_proto(),
}
}
@@ -123,6 +129,7 @@ impl LspCommand for ExpandMacro {
.context("invalid position")?;
Ok(Self {
position: buffer.read_with(&cx, |buffer, _| position.to_point_utf16(buffer)),
+ server_id: LanguageServerId::from_proto(message.server_id),
})
}
@@ -188,6 +195,7 @@ impl DocsUrls {
#[derive(Debug)]
pub struct OpenDocs {
pub position: PointUtf16,
+ pub server_id: LanguageServerId,
}
#[async_trait(?Send)]
@@ -204,6 +212,10 @@ impl LspCommand for OpenDocs {
true
}
+ fn language_server_to_query(&self) -> LanguageServerToQuery {
+ LanguageServerToQuery::Other(self.server_id)
+ }
+
fn to_lsp(
&self,
path: &Path,
@@ -242,6 +254,7 @@ impl LspCommand for OpenDocs {
position: Some(language::proto::serialize_anchor(
&buffer.anchor_before(self.position),
)),
+ server_id: self.server_id.to_proto(),
}
}
@@ -257,6 +270,7 @@ impl LspCommand for OpenDocs {
.context("invalid position")?;
Ok(Self {
position: buffer.read_with(&cx, |buffer, _| position.to_point_utf16(buffer)),
+ server_id: LanguageServerId::from_proto(message.server_id),
})
}
@@ -307,13 +321,15 @@ pub struct SwitchSourceHeaderParams(lsp::TextDocumentIdentifier);
#[serde(rename_all = "camelCase")]
pub struct SwitchSourceHeaderResult(pub String);
-#[derive(Default, Deserialize, Serialize, Debug)]
-#[serde(rename_all = "camelCase")]
-pub struct SwitchSourceHeader;
+#[derive(Debug)]
+pub struct SwitchSourceHeader {
+ pub server_id: LanguageServerId,
+}
#[derive(Debug)]
pub struct GoToParentModule {
pub position: PointUtf16,
+ pub server_id: LanguageServerId,
}
pub struct LspGoToParentModule {}
@@ -338,6 +354,10 @@ impl LspCommand for SwitchSourceHeader {
true
}
+ fn language_server_to_query(&self) -> LanguageServerToQuery {
+ LanguageServerToQuery::Other(self.server_id)
+ }
+
fn to_lsp(
&self,
path: &Path,
@@ -367,16 +387,19 @@ impl LspCommand for SwitchSourceHeader {
proto::LspExtSwitchSourceHeader {
project_id,
buffer_id: buffer.remote_id().into(),
+ server_id: self.server_id.to_proto(),
}
}
async fn from_proto(
- _: Self::ProtoRequest,
+ message: Self::ProtoRequest,
_: Entity,
_: Entity,
_: AsyncApp,
) -> anyhow::Result {
- Ok(Self {})
+ Ok(Self {
+ server_id: LanguageServerId::from_proto(message.server_id),
+ })
}
fn response_to_proto(
@@ -420,6 +443,10 @@ impl LspCommand for GoToParentModule {
true
}
+ fn language_server_to_query(&self) -> LanguageServerToQuery {
+ LanguageServerToQuery::Other(self.server_id)
+ }
+
fn to_lsp(
&self,
path: &Path,
@@ -455,6 +482,7 @@ impl LspCommand for GoToParentModule {
position: Some(language::proto::serialize_anchor(
&buffer.anchor_before(self.position),
)),
+ server_id: self.server_id.to_proto(),
}
}
@@ -470,6 +498,7 @@ impl LspCommand for GoToParentModule {
.context("bad request with bad position")?;
Ok(Self {
position: buffer.read_with(&cx, |buffer, _| position.to_point_utf16(buffer)),
+ server_id: LanguageServerId::from_proto(request.server_id),
})
}
@@ -606,6 +635,7 @@ pub struct ShellRunnableArgs {
pub struct GetLspRunnables {
pub buffer_id: BufferId,
pub position: Option,
+ pub server_id: LanguageServerId,
}
#[derive(Debug, Default)]
@@ -677,6 +707,10 @@ impl LspCommand for GetLspRunnables {
true
}
+ fn language_server_to_query(&self) -> LanguageServerToQuery {
+ LanguageServerToQuery::Other(self.server_id)
+ }
+
fn to_lsp(
&self,
path: &Path,
@@ -723,6 +757,7 @@ impl LspCommand for GetLspRunnables {
project_id,
buffer_id: buffer.remote_id().to_proto(),
position: self.position.as_ref().map(serialize_anchor),
+ server_id: self.server_id.to_proto(),
}
}
@@ -737,6 +772,7 @@ impl LspCommand for GetLspRunnables {
Ok(Self {
buffer_id,
position,
+ server_id: LanguageServerId::from_proto(message.server_id),
})
}
diff --git a/crates/project/src/project.rs b/crates/project/src/project.rs
index ed6627f66c..186af48708 100644
--- a/crates/project/src/project.rs
+++ b/crates/project/src/project.rs
@@ -6332,14 +6332,16 @@ impl Project {
if !relevant_language_servers.contains(name) {
return None;
}
+ let opened_in_servers = self
+ .lsp_store
+ .read(cx)
+ .language_server_ids_for_opened_buffer(buffer.remote_id());
self.language_server_statuses(cx)
.filter(|(_, server_status)| relevant_language_servers.contains(&server_status.name))
.find_map(|(server_id, server_status)| {
- if &server_status.name == name {
- Some(server_id)
- } else {
- None
- }
+ (&server_status.name == name
+ && opened_in_servers.is_none_or(|server_ids| server_ids.contains(&server_id)))
+ .then_some(server_id)
})
}
diff --git a/crates/proto/proto/lsp.proto b/crates/proto/proto/lsp.proto
index c07d33857b..cf7080a719 100644
--- a/crates/proto/proto/lsp.proto
+++ b/crates/proto/proto/lsp.proto
@@ -299,6 +299,7 @@ message LspExtExpandMacro {
uint64 project_id = 1;
uint64 buffer_id = 2;
Anchor position = 3;
+ uint64 server_id = 4;
}
message LspExtExpandMacroResponse {
@@ -306,10 +307,28 @@ message LspExtExpandMacroResponse {
string expansion = 2;
}
+message LspExtExpandAbbreviation {
+ uint64 project_id = 1;
+ uint64 buffer_id = 2;
+ string abbreviation = 3;
+ repeated string text = 4;
+ string language = 5;
+ uint64 server_id = 6;
+ bool comment_filter = 7;
+ bool bem_filter = 8;
+ string indent = 9;
+ string base_indent = 10;
+}
+
+message LspExtExpandAbbreviationResponse {
+ optional string expansion = 1;
+}
+
message LspExtOpenDocs {
uint64 project_id = 1;
uint64 buffer_id = 2;
Anchor position = 3;
+ uint64 server_id = 4;
}
message LspExtOpenDocsResponse {
@@ -320,6 +339,7 @@ message LspExtOpenDocsResponse {
message LspExtSwitchSourceHeader {
uint64 project_id = 1;
uint64 buffer_id = 2;
+ uint64 server_id = 3;
}
message LspExtSwitchSourceHeaderResponse {
@@ -330,6 +350,7 @@ message LspExtGoToParentModule {
uint64 project_id = 1;
uint64 buffer_id = 2;
Anchor position = 3;
+ uint64 server_id = 4;
}
message LspExtGoToParentModuleResponse {
@@ -1068,6 +1089,7 @@ message LspExtRunnables {
uint64 project_id = 1;
uint64 buffer_id = 2;
optional Anchor position = 3;
+ uint64 server_id = 4;
}
message LspExtRunnablesResponse {
diff --git a/crates/proto/proto/zed.proto b/crates/proto/proto/zed.proto
index c9cf63b4ba..e89edf3cde 100644
--- a/crates/proto/proto/zed.proto
+++ b/crates/proto/proto/zed.proto
@@ -516,7 +516,9 @@ message Envelope {
GetIncomingCalls get_incoming_calls = 484;
GetIncomingCallsResponse get_incoming_calls_response = 485;
GetOutgoingCalls get_outgoing_calls = 486;
- GetOutgoingCallsResponse get_outgoing_calls_response = 487; // current max
+ GetOutgoingCallsResponse get_outgoing_calls_response = 487;
+ LspExtExpandAbbreviation lsp_ext_expand_abbreviation = 488;
+ LspExtExpandAbbreviationResponse lsp_ext_expand_abbreviation_response = 489; // current max
}
reserved 87 to 88;
diff --git a/crates/proto/src/proto.rs b/crates/proto/src/proto.rs
index e851c933cd..103ec4f1f9 100644
--- a/crates/proto/src/proto.rs
+++ b/crates/proto/src/proto.rs
@@ -176,6 +176,8 @@ messages!(
(LoadCommitDiffResponse, Foreground),
(LspExtExpandMacro, Background),
(LspExtExpandMacroResponse, Background),
+ (LspExtExpandAbbreviation, Background),
+ (LspExtExpandAbbreviationResponse, Background),
(LspExtOpenDocs, Background),
(LspExtOpenDocsResponse, Background),
(LspExtRunnables, Background),
@@ -547,6 +549,7 @@ request_messages!(
(UpdateRepository, Ack),
(RemoveRepository, Ack),
(LspExtExpandMacro, LspExtExpandMacroResponse),
+ (LspExtExpandAbbreviation, LspExtExpandAbbreviationResponse),
(LspExtOpenDocs, LspExtOpenDocsResponse),
(LspExtRunnables, LspExtRunnablesResponse),
(SetRoomParticipantRole, Ack),
@@ -784,6 +787,7 @@ entity_messages!(
UpdateWorktreeSettings,
UpdateUserSettings,
LspExtExpandMacro,
+ LspExtExpandAbbreviation,
LspExtOpenDocs,
LspExtRunnables,
LspExtSwitchSourceHeader,
diff --git a/docs/src/languages/emmet.md b/docs/src/languages/emmet.md
index 1ef6a1dea9..b78ca96aa9 100644
--- a/docs/src/languages/emmet.md
+++ b/docs/src/languages/emmet.md
@@ -11,6 +11,39 @@ Emmet support is available through the [Emmet extension](https://github.com/zed-
- Language Server: [olrtg/emmet-language-server](https://github.com/olrtg/emmet-language-server)
+## Wrap with abbreviation
+
+With the Emmet extension installed, you can wrap the current selections in an expanded [Emmet abbreviation](https://docs.emmet.io/abbreviations/):
+
+1. Select the text to wrap. With an empty selection, the enclosing HTML element is wrapped; if no enclosing element can be determined, the current line is wrapped instead.
+2. Run `editor: wrap with abbreviation` from the command palette. The last used abbreviation is prefilled and selected, so typing replaces it; `up` and `down` cycle through previously used abbreviations.
+3. Type an abbreviation, for example `div.wrapper>ul>li*3`, into the input that appears below the selection. A preview of the expansion is shown under the input as you type.
+4. Press `enter` to apply, or `escape` to cancel. If the abbreviation produces no expansion, the error is shown next to the input and the input stays open so you can correct it.
+
+For a single selection, the expansion is inserted as a snippet and the cursor is placed at the end of the expansion. With multiple selections, each selection is wrapped separately; overlapping selections are merged and wrapped once.
+
+The [documented wrap behaviors](https://docs.emmet.io/actions/wrap-with-abbreviation/) are supported:
+
+- Wrapping individual lines: mark the repeating element with `*` (without a number), for example `ul>li*`, and each selected line is wrapped in its own element. `$` numbering works too: `ul>li.item$*>a`.
+- Removing list markers: append the trim filter `|t`, for example `ul>li*|t`, to strip list markers such as `*`, `-`, or `1.` from the wrapped lines.
+- Controlling output position: use the `$#` placeholder inside attribute values or text nodes, for example `ul>li[title=$#]*>{$#}`.
+- The [comment filter](https://docs.emmet.io/filters/comment/) `|c` and the [BEM filter](https://docs.emmet.io/filters/bem/) `|bem` are also supported and can be combined, for example `div#page>p.intro|c|bem`.
+
+Selections that partially cover an opening or closing tag are expanded to the whole element before wrapping. Wrapping is a markup action: it is not offered in stylesheets, where Emmet provides completions instead.
+
+The action has no default keybinding. To add one, bind it in your keymap:
+
+```json [keymap]
+[
+ {
+ "context": "Editor && mode == full",
+ "bindings": {
+ "ctrl-alt-w": "editor::WrapWithAbbreviation"
+ }
+ }
+]
+```
+