From 74f3fa14548798720cc22e561c6a0dd3826dae6b Mon Sep 17 00:00:00 2001 From: Kirill Bulatov Date: Wed, 2 Sep 2026 18:21:02 +0000 Subject: [PATCH] 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 --- assets/keymaps/default-linux.json | 6 + assets/keymaps/default-macos.json | 7 + assets/keymaps/default-windows.json | 7 + crates/collab/src/rpc.rs | 3 + .../collab/tests/integration/editor_tests.rs | 158 +- crates/editor/src/actions.rs | 2 + crates/editor/src/clangd_ext.rs | 5 +- crates/editor/src/editor.rs | 34 +- crates/editor/src/element.rs | 7 + crates/editor/src/emmet_ext.rs | 1628 +++++++++++++++++ crates/editor/src/inline_input.rs | 370 ++++ crates/editor/src/lsp_ext.rs | 1 + crates/editor/src/navigation.rs | 6 + crates/editor/src/rust_analyzer_ext.rs | 18 +- crates/editor/src/selection.rs | 1 + crates/editor/src/test.rs | 1 + crates/language/src/language_registry.rs | 14 + crates/project/src/lsp_command.rs | 6 +- crates/project/src/lsp_store.rs | 38 +- crates/project/src/lsp_store/emmet_ext.rs | 186 ++ .../project/src/lsp_store/lsp_ext_command.rs | 48 +- crates/project/src/project.rs | 12 +- crates/proto/proto/lsp.proto | 22 + crates/proto/proto/zed.proto | 4 +- crates/proto/src/proto.rs | 4 + docs/src/languages/emmet.md | 33 + 26 files changed, 2581 insertions(+), 40 deletions(-) create mode 100644 crates/editor/src/emmet_ext.rs create mode 100644 crates/editor/src/inline_input.rs create mode 100644 crates/project/src/lsp_store/emmet_ext.rs 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("
\n

hello

\n
", 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("

hello

".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), + "
\n

hello

\n
" + ); + }); + } + + #[gpui::test] + async fn test_multiline_expansion_preserves_surrounding_indentation(cx: &mut TestAppContext) { + let (editor, mut fake_servers, cx) = + setup("
\n

a

\n

b

\n
", 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( + "
\n\t

a

\n\t

b

\n
", + ¶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), + "
\n
\n

a

\n

b

\n
\n
", + "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("
\n

a

\n

b

\n
", 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( + "
\n\t

a

\n\t

b

\n
", + ¶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( + "
\n

a

\n

b

\n
".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", 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\t
  • About
  • \n\t
  • News
  • \n\t
  • Products
  • \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( + "
    \n\t
  • one
  • \n\t
  • two
  • \n
", + ¶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
  • one
  • \n
  • two
  • \n
\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("

hello

".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), "

hello

"); + }); + } + + #[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("

a

b

xy
", 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!["

a

b

xy
".to_string()]), + "the element expanded from the trailing caret should be merged \ + with the earlier selections into a single wrap target" + ); + Ok(Some( + "

a

b

xy
".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), + "

a

b

xy
" + ); + 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" + } + } +] +``` +