diff --git a/crates/editor/src/actions.rs b/crates/editor/src/actions.rs index 0bf236769efd17c26ebd55d4dda010d454ba71e4..7d6f486974d1ef7e792bd79997aebd332c2336f4 100644 --- a/crates/editor/src/actions.rs +++ b/crates/editor/src/actions.rs @@ -327,6 +327,23 @@ pub struct AddSelectionBelow { pub skip_soft_wrap: bool, } +/// Inserts a snippet at the cursor. +#[derive(PartialEq, Clone, Default, Debug, Deserialize, JsonSchema, Action)] +#[action(namespace = editor)] +#[serde(deny_unknown_fields)] +pub struct InsertSnippet { + /// Language name if using a named snippet, or `None` for a global snippet + /// + /// This is typically lowercase and matches the filename containing the snippet, without the `.json` extension. + pub language: Option, + /// Name if using a named snippet + pub name: Option, + + /// Snippet body, if not using a named snippet + // todo(andrew): use `ListOrDirect` or similar for multiline snippet body + pub snippet: Option, +} + actions!( debugger, [ diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 223c0e574cff7daa9f50b07735b40e291185a37c..d173c1cb4aac782283a3832b5e411a0a44cc1f23 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -79,7 +79,7 @@ use ::git::{ status::FileStatus, }; use aho_corasick::{AhoCorasick, AhoCorasickBuilder, BuildError}; -use anyhow::{Context as _, Result, anyhow}; +use anyhow::{Context as _, Result, anyhow, bail}; use blink_manager::BlinkManager; use buffer_diff::DiffHunkStatus; use client::{Collaborator, ParticipantIndex, parse_zed_link}; @@ -14811,6 +14811,52 @@ impl Editor { } } + pub fn insert_snippet_at_selections( + &mut self, + action: &InsertSnippet, + window: &mut Window, + cx: &mut Context, + ) { + self.try_insert_snippet_at_selections(action, window, cx) + .log_err(); + } + + fn try_insert_snippet_at_selections( + &mut self, + action: &InsertSnippet, + window: &mut Window, + cx: &mut Context, + ) -> Result<()> { + let insertion_ranges = self + .selections + .all::(&self.display_snapshot(cx)) + .into_iter() + .map(|selection| selection.range()) + .collect_vec(); + + let snippet = if let Some(snippet_body) = &action.snippet { + if action.language.is_none() && action.name.is_none() { + Snippet::parse(snippet_body)? + } else { + bail!("`snippet` is mutually exclusive with `language` and `name`") + } + } else if let Some(name) = &action.name { + let project = self.project().context("no project")?; + let snippet_store = project.read(cx).snippets().read(cx); + let snippet = snippet_store + .snippets_for(action.language.clone(), cx) + .into_iter() + .find(|snippet| snippet.name == *name) + .context("snippet not found")?; + Snippet::parse(&snippet.body)? + } else { + // todo(andrew): open modal to select snippet + bail!("`name` or `snippet` is required") + }; + + self.insert_snippet(&insertion_ranges, snippet, window, cx) + } + fn select_match_ranges( &mut self, range: Range, diff --git a/crates/editor/src/editor_tests.rs b/crates/editor/src/editor_tests.rs index 75aa29be7a31ef6a4707e538ea21a2ca45aff395..3bd5e6bf8f7947dfc9ac26f8ecbe9b6554151fcb 100644 --- a/crates/editor/src/editor_tests.rs +++ b/crates/editor/src/editor_tests.rs @@ -26895,6 +26895,82 @@ async fn test_add_selection_skip_soft_wrap_option(cx: &mut TestAppContext) { }); } +#[gpui::test] +async fn test_insert_snippet(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + + cx.update_editor(|editor, _, cx| { + editor.project().unwrap().update(cx, |project, cx| { + project.snippets().update(cx, |snippets, _cx| { + let snippet = project::snippet_provider::Snippet { + prefix: vec![], // no prefix needed! + body: "an Unspecified".to_string(), + description: Some("shhhh it's a secret".to_string()), + name: "super secret snippet".to_string(), + }; + snippets.add_snippet_for_test( + None, + PathBuf::from("test_snippets.json"), + vec![Arc::new(snippet)], + ); + + let snippet = project::snippet_provider::Snippet { + prefix: vec![], // no prefix needed! + body: " Location".to_string(), + description: Some("the word 'location'".to_string()), + name: "location word".to_string(), + }; + snippets.add_snippet_for_test( + Some("Markdown".to_string()), + PathBuf::from("test_snippets.json"), + vec![Arc::new(snippet)], + ); + }); + }) + }); + + cx.set_state(indoc!(r#"First cursor at ˇ and second cursor at ˇ"#)); + + cx.update_editor(|editor, window, cx| { + editor.insert_snippet_at_selections( + &InsertSnippet { + language: None, + name: Some("super secret snippet".to_string()), + snippet: None, + }, + window, + cx, + ); + + // Language is specified in the action, + // so the buffer language does not need to match + editor.insert_snippet_at_selections( + &InsertSnippet { + language: Some("Markdown".to_string()), + name: Some("location word".to_string()), + snippet: None, + }, + window, + cx, + ); + + editor.insert_snippet_at_selections( + &InsertSnippet { + language: None, + name: None, + snippet: Some("$0 after".to_string()), + }, + window, + cx, + ); + }); + + cx.assert_editor_state( + r#"First cursor at an Unspecified Locationˇ after and second cursor at an Unspecified Locationˇ after"#, + ); +} + #[gpui::test(iterations = 10)] async fn test_document_colors(cx: &mut TestAppContext) { let expected_color = Rgba { diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs index edb3778ff94809ef880ffa167f2ff410a3199a37..5e5749494017479b921a2bbdb2af8fb7d62c9bf4 100644 --- a/crates/editor/src/element.rs +++ b/crates/editor/src/element.rs @@ -365,6 +365,7 @@ impl EditorElement { register_action(editor, window, Editor::split_selection_into_lines); register_action(editor, window, Editor::add_selection_above); register_action(editor, window, Editor::add_selection_below); + register_action(editor, window, Editor::insert_snippet_at_selections); register_action(editor, window, |editor, action, window, cx| { editor.select_next(action, window, cx).log_err(); }); diff --git a/crates/snippet_provider/src/lib.rs b/crates/snippet_provider/src/lib.rs index 64711cfc3a7247f6250b65e4f7325dd0bfdc1dcb..5eff6c917f9b8198db6149ad07dc2fdf905a9223 100644 --- a/crates/snippet_provider/src/lib.rs +++ b/crates/snippet_provider/src/lib.rs @@ -22,7 +22,7 @@ pub fn init(cx: &mut App) { extension_snippet::init(cx); } -// Is `None` if the snippet file is global. +/// Language name, or `None` if the snippet file is global. type SnippetKind = Option; fn file_stem_to_key(stem: &str) -> SnippetKind { if stem == "snippets" {