Support Emmet's wrap with abbreviation (#63383)

Closes https://github.com/zed-industries/zed/issues/15588


https://github.com/user-attachments/assets/2813d235-6dac-4fe6-9ae0-2783e16270ac

Release Notes:

- Added Emmet's wrap with abbreviation support
This commit is contained in:
Kirill Bulatov authored and GitHub committed 2026-09-02 18:21:02 +00:00
1 parent 7e7087a589
commit 74f3fa1454
26 files changed
+2581 -40

No files matched your search

+6
View File
@@ -834,6 +834,12 @@
"enter": "editor::ConfirmRename",
},
},
{
"context": "Editor && inline_input",
"bindings": {
"enter": "menu::Confirm",
},
},
{
"context": "Editor && showing_completions",
"bindings": {
+7
View File
@@ -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,
+7
View File
@@ -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,
+3
View File
@@ -376,6 +376,9 @@ impl Server {
.add_request_handler(forward_read_only_project_request::<proto::OpenUnstagedDiff>)
.add_request_handler(forward_read_only_project_request::<proto::OpenUncommittedDiff>)
.add_request_handler(forward_read_only_project_request::<proto::LspExtExpandMacro>)
.add_request_handler(
forward_read_only_project_request::<proto::LspExtExpandAbbreviation>,
)
.add_request_handler(forward_read_only_project_request::<proto::LspExtOpenDocs>)
.add_request_handler(forward_mutating_project_request::<proto::LspExtRunnables>)
.add_request_handler(
+155 -3
View File
@@ -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": "<p>hello</p>",
}),
)
.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::<Editor>()
.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::<LspExpandAbbreviation, _, _>(
|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("<div class=\"wrap\">hello</div>".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), "<p><div class=\"wrap\">hello</div></p>");
});
buffer_a.read_with(cx_a, |buffer, _| {
assert_eq!(
buffer.text(),
"<p><div class=\"wrap\">hello</div></p>",
"the wrap edit should sync back to the host"
);
});
}
#[gpui::test]
async fn test_copy_file_name_without_extension(
cx_a: &mut TestAppContext,
+2
View File
@@ -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.
+4 -1
View File
@@ -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,
)
})
+30 -4
View File
@@ -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<Task<Option<()>>>,
linked_edit_ranges: linked_editing_ranges::LinkedEditingRanges,
pending_rename: Option<RenameState>,
pending_inline_input: Option<InlineInputState>,
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<Self>,
) -> 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<MultiBufferOffset>],
snippet: Snippet,
autoindent_mode: Option<AutoindentMode>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Result<()> {
struct Tabstop<T> {
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;
+7
View File
@@ -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);
File diff suppressed because it is too large. Load diff
+370
View File
@@ -0,0 +1,370 @@
use gpui::StyledText;
use language::HighlightId;
use super::*;
pub struct InlineInputState {
pub editor: Entity<Editor>,
block_id: CustomBlockId,
position: Anchor,
confirm_task: Option<Task<()>>,
preview_task: Option<Task<()>>,
pub(crate) preview: Option<InlineInputPreview>,
preview_language: Option<Arc<Language>>,
block_height: u32,
history: Vec<SharedString>,
history_ix: Option<usize>,
draft: String,
_subscription: Subscription,
on_confirm:
Rc<dyn Fn(&mut Editor, String, &mut Window, &mut Context<Editor>) -> Option<Task<()>>>,
}
impl InlineInputState {
pub(crate) fn take_confirm_task(&mut self) -> Option<Task<()>> {
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::<Vec<_>>().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<usize>, HighlightId)>,
height_in_lines: u32,
is_error: bool,
}
impl RenderedPreview {
fn new(
preview: &InlineInputPreview,
language: Option<&Arc<Language>>,
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<Self>) {
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<Arc<Language>>,
history: Vec<SharedString>,
on_confirm: impl Fn(&mut Editor, String, &mut Window, &mut Context<Editor>) -> Option<Task<()>>
+ 'static,
on_change: impl Fn(&mut Editor, String, &mut Window, &mut Context<Editor>) -> Option<Task<()>>
+ 'static,
window: &mut Window,
cx: &mut Context<Self>,
) {
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<InlineInputPreview>,
cx: &mut Context<Self>,
) {
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<Self>,
) -> 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<Self>,
) -> Option<InlineInputState> {
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<Editor>,
preview: Option<RenderedPreview>,
) -> 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::<Vec<_>>()
.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::<Vec<_>>().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));
}
}
+1
View File
@@ -151,6 +151,7 @@ pub fn lsp_tasks(
GetLspRunnables {
buffer_id,
position: for_position,
server_id,
},
cx,
)
+6
View File
@@ -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();
+15 -3
View File
@@ -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,
)
})
+1
View File
@@ -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();
+1
View File
@@ -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,
+14
View File
@@ -894,6 +894,20 @@ impl LanguageRegistry {
.unwrap_or_default()
}
pub fn lsp_adapter(
&self,
language_name: &LanguageName,
server_name: &LanguageServerName,
) -> Option<Arc<CachedLspAdapter>> {
self.state
.read()
.lsp_adapters
.get(language_name)?
.iter()
.find(|adapter| adapter.name() == *server_name)
.cloned()
}
pub fn all_lsp_adapters(&self) -> Vec<Arc<CachedLspAdapter>> {
self.state
.read()
+5 -1
View File
@@ -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,
+22 -16
View File
@@ -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::<lsp_ext_command::ExpandMacro>);
client
.add_entity_request_handler(Self::handle_lsp_command::<emmet_ext::ExpandAbbreviation>);
client.add_entity_request_handler(Self::handle_lsp_command::<lsp_ext_command::OpenDocs>);
client.add_entity_request_handler(
Self::handle_lsp_command::<lsp_ext_command::GoToParentModule>,
@@ -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<LanguageServerId>> {
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| {
+186
View File
@@ -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<String>;
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<Vec<String>>,
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<bool>,
#[serde(rename = "bem.enabled", skip_serializing_if = "Option::is_none")]
pub bem_enabled: Option<bool>,
#[serde(rename = "output.inlineBreak", skip_serializing_if = "Option::is_none")]
pub inline_break: Option<u32>,
}
#[derive(Debug)]
pub struct ExpandAbbreviation {
pub abbreviation: String,
pub text: Option<Vec<String>>,
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<String>;
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<LanguageServer>,
_: &App,
) -> Result<ExpandAbbreviationParams> {
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<String>,
_: Entity<LspStore>,
_: Entity<Buffer>,
_: LanguageServerId,
_: AsyncApp,
) -> anyhow::Result<Option<String>> {
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<LspStore>,
_: Entity<Buffer>,
_: AsyncApp,
) -> anyhow::Result<Self> {
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<String>,
_: &mut LspStore,
_: PeerId,
_: &clock::Global,
_: &mut App,
) -> proto::LspExtExpandAbbreviationResponse {
proto::LspExtExpandAbbreviationResponse {
expansion: response,
}
}
async fn response_from_proto(
self,
message: proto::LspExtExpandAbbreviationResponse,
_: Entity<LspStore>,
_: Entity<Buffer>,
_: AsyncApp,
) -> anyhow::Result<Option<String>> {
Ok(message.expansion.filter(|expansion| !expansion.is_empty()))
}
fn buffer_id_from_proto(message: &proto::LspExtExpandAbbreviation) -> Result<BufferId> {
BufferId::new(message.buffer_id)
}
}
@@ -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<LspStore>,
_: Entity<Buffer>,
_: AsyncApp,
) -> anyhow::Result<Self> {
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<text::Anchor>,
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),
})
}
+7 -5
View File
@@ -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)
})
}
+22
View File
@@ -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 {
+3 -1
View File
@@ -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;
+4
View File
@@ -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,
+33
View File
@@ -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"
}
}
]
```
<!--
TBD: Document Emmet usage in zed with: HTML, PHP, ERB, Javascript, TSX, CSS
-->