Add `editor::InsertSnippet` action (#44428)
Andrew Farkas
created 2 weeks ago
Closes #20036
This introduces new action `editor: insert snippet`. It supports three
modes:
```
["editor::InsertSnippet", {"name": "snippet_name"}]
["editor::InsertSnippet", {"language": "language_name", "name": "snippet_name"}]
["editor::InsertSnippet", {"snippet": "snippet with $1 tab stops"}]
```
## Example usage
### `keymap.json`
```json
{
"context": "Editor",
"bindings": {
// named global snippet
"cmd-k cmd-r": ["editor::InsertSnippet", {"name": "all rights reserved"}],
// named language-specific snippet
"cmd-k cmd-p": ["editor::InsertSnippet", {"language": "rust", "name": "debug-print a value"}],
// inline snippet
"cmd-k cmd-e": ["editor::InsertSnippet", {"snippet": "println!(\"This snippet has multiple lines.\")\nprintln!(\"It belongs to $1 and is very $2.\")"}],
},
},
```
### `~/.config/zed/snippets/rust.json`
```json
{
"debug-print a value": {
"body": "println!(\"$1 = {:?}\", $1)",
},
}
```
### `~/.config/zed/snippets/snippets.json`
```json
{
"all rights reserved": {
"body": "Copyright © ${1:2025} ${2:your name}. All rights reserved.",
},
}
```
## Future extensions
- Support multiline inline snippets using an array of strings using
something similar to `ListOrDirect` in
`snippet_provider::format::VsCodeSnippet`
- When called with no arguments, open a modal to select a snippet to
insert
## Release notes
Release Notes:
- Added `editor::InsertSnippet` action
Detailed changes
@@ -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<String>,
+ /// Name if using a named snippet
+ pub name: Option<String>,
+
+ /// Snippet body, if not using a named snippet
+ // todo(andrew): use `ListOrDirect` or similar for multiline snippet body
+ pub snippet: Option<String>,
+}
+
actions!(
debugger,
[
@@ -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>,
+ ) {
+ 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<Self>,
+ ) -> Result<()> {
+ let insertion_ranges = self
+ .selections
+ .all::<MultiBufferOffset>(&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<MultiBufferOffset>,
@@ -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 {
@@ -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();
});
@@ -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<String>;
fn file_stem_to_key(stem: &str) -> SnippetKind {
if stem == "snippets" {