crates/editor/src/debounced_delay.rs ๐
@@ -5,6 +5,7 @@ use gpui::{Task, ViewContext};
use crate::Editor;
+#[derive(Debug)]
pub struct DebouncedDelay {
task: Option<Task<()>>,
cancel_channel: Option<oneshot::Sender<()>>,
Anthony Eid , Piotr Osiewicz , and Piotr Osiewicz created
Closes: #12739
Release Notes:
Solves #12739 by
- Enable snippet parsing to successfully parse snippets with choices
- Show completion menu when tabbing to a snippet variable with multiple
choices
Todo:
- [x] Parse snippet choices
- [x] Open completion menu when tabbing to a snippet variable with
several choices (Thank you Piotr)
- [x] Get snippet choices to reappear when tabbing back to a previous
tabstop in a snippet
- [x] add snippet unit tests
- [x] Add fuzzy search to snippet choice completion menu & update
completion menu based on choices
- [x] add completion menu unit tests
Current State:
Using these custom snippets
```json
"my snippet": {
"prefix": "log",
"body": ["type ${1|i32, u32|} = $2"],
"description": "Expand `log` to `console.log()`"
},
"my snippet2": {
"prefix": "snip",
"body": [
"type ${1|i,i8,i16,i64,i32|} ${2|test,test_again,test_final|} = $3"
],
"description": "snippet choice tester"
}
```
Using snippet choices:
https://github.com/user-attachments/assets/d29fb1a2-7632-4071-944f-daeaa243e3ac
---------
Co-authored-by: Piotr Osiewicz <piotr@zed.dev>
Co-authored-by: Piotr Osiewicz <24362066+osiewicz@users.noreply.github.com>
crates/editor/src/debounced_delay.rs | 1
crates/editor/src/editor.rs | 201 ++++++++++++++++++++++++-----
crates/editor/src/editor_tests.rs | 39 +++++
crates/snippet/src/snippet.rs | 119 +++++++++++++++++
4 files changed, 321 insertions(+), 39 deletions(-)
@@ -5,6 +5,7 @@ use gpui::{Task, ViewContext};
use crate::Editor;
+#[derive(Debug)]
pub struct DebouncedDelay {
task: Option<Task<()>>,
cancel_channel: Option<oneshot::Sender<()>>,
@@ -883,6 +883,7 @@ struct AutocloseRegion {
struct SnippetState {
ranges: Vec<Vec<Range<Anchor>>>,
active_index: usize,
+ choices: Vec<Option<Vec<String>>>,
}
#[doc(hidden)]
@@ -1000,7 +1001,7 @@ enum ContextMenuOrigin {
GutterIndicator(DisplayRow),
}
-#[derive(Clone)]
+#[derive(Clone, Debug)]
struct CompletionsMenu {
id: CompletionId,
sort_completions: bool,
@@ -1011,10 +1012,100 @@ struct CompletionsMenu {
matches: Arc<[StringMatch]>,
selected_item: usize,
scroll_handle: UniformListScrollHandle,
- selected_completion_documentation_resolve_debounce: Arc<Mutex<DebouncedDelay>>,
+ selected_completion_documentation_resolve_debounce: Option<Arc<Mutex<DebouncedDelay>>>,
}
impl CompletionsMenu {
+ fn new(
+ id: CompletionId,
+ sort_completions: bool,
+ initial_position: Anchor,
+ buffer: Model<Buffer>,
+ completions: Box<[Completion]>,
+ ) -> Self {
+ let match_candidates = completions
+ .iter()
+ .enumerate()
+ .map(|(id, completion)| StringMatchCandidate::new(id, completion.label.text.clone()))
+ .collect();
+
+ Self {
+ id,
+ sort_completions,
+ initial_position,
+ buffer,
+ completions: Arc::new(RwLock::new(completions)),
+ match_candidates,
+ matches: Vec::new().into(),
+ selected_item: 0,
+ scroll_handle: UniformListScrollHandle::new(),
+ selected_completion_documentation_resolve_debounce: Some(Arc::new(Mutex::new(
+ DebouncedDelay::new(),
+ ))),
+ }
+ }
+
+ fn new_snippet_choices(
+ id: CompletionId,
+ sort_completions: bool,
+ choices: &Vec<String>,
+ selection: Range<Anchor>,
+ buffer: Model<Buffer>,
+ ) -> Self {
+ let completions = choices
+ .iter()
+ .map(|choice| Completion {
+ old_range: selection.start.text_anchor..selection.end.text_anchor,
+ new_text: choice.to_string(),
+ label: CodeLabel {
+ text: choice.to_string(),
+ runs: Default::default(),
+ filter_range: Default::default(),
+ },
+ server_id: LanguageServerId(usize::MAX),
+ documentation: None,
+ lsp_completion: Default::default(),
+ confirm: None,
+ })
+ .collect();
+
+ let match_candidates = choices
+ .iter()
+ .enumerate()
+ .map(|(id, completion)| StringMatchCandidate::new(id, completion.to_string()))
+ .collect();
+ let matches = choices
+ .iter()
+ .enumerate()
+ .map(|(id, completion)| StringMatch {
+ candidate_id: id,
+ score: 1.,
+ positions: vec![],
+ string: completion.clone(),
+ })
+ .collect();
+ Self {
+ id,
+ sort_completions,
+ initial_position: selection.start,
+ buffer,
+ completions: Arc::new(RwLock::new(completions)),
+ match_candidates,
+ matches,
+ selected_item: 0,
+ scroll_handle: UniformListScrollHandle::new(),
+ selected_completion_documentation_resolve_debounce: Some(Arc::new(Mutex::new(
+ DebouncedDelay::new(),
+ ))),
+ }
+ }
+
+ fn suppress_documentation_resolution(mut self) -> Self {
+ self.selected_completion_documentation_resolve_debounce
+ .take();
+ self
+ }
+
fn select_first(
&mut self,
provider: Option<&dyn CompletionProvider>,
@@ -1115,6 +1206,12 @@ impl CompletionsMenu {
let Some(provider) = provider else {
return;
};
+ let Some(documentation_resolve) = self
+ .selected_completion_documentation_resolve_debounce
+ .as_ref()
+ else {
+ return;
+ };
let resolve_task = provider.resolve_completions(
self.buffer.clone(),
@@ -1127,15 +1224,13 @@ impl CompletionsMenu {
EditorSettings::get_global(cx).completion_documentation_secondary_query_debounce;
let delay = Duration::from_millis(delay_ms);
- self.selected_completion_documentation_resolve_debounce
- .lock()
- .fire_new(delay, cx, |_, cx| {
- cx.spawn(move |this, mut cx| async move {
- if let Some(true) = resolve_task.await.log_err() {
- this.update(&mut cx, |_, cx| cx.notify()).ok();
- }
- })
- });
+ documentation_resolve.lock().fire_new(delay, cx, |_, cx| {
+ cx.spawn(move |this, mut cx| async move {
+ if let Some(true) = resolve_task.await.log_err() {
+ this.update(&mut cx, |_, cx| cx.notify()).ok();
+ }
+ })
+ });
}
fn visible(&self) -> bool {
@@ -1418,6 +1513,7 @@ impl CompletionsMenu {
}
}
+#[derive(Clone)]
struct AvailableCodeAction {
excerpt_id: ExcerptId,
action: CodeAction,
@@ -4386,6 +4482,10 @@ impl Editor {
return;
};
+ if !self.snippet_stack.is_empty() && self.context_menu.read().as_ref().is_some() {
+ return;
+ }
+
let position = self.selections.newest_anchor().head();
let (buffer, buffer_position) =
if let Some(output) = self.buffer.read(cx).text_anchor_for_position(position, cx) {
@@ -4431,30 +4531,13 @@ impl Editor {
})?;
let completions = completions.await.log_err();
let menu = if let Some(completions) = completions {
- let mut menu = CompletionsMenu {
+ let mut menu = CompletionsMenu::new(
id,
sort_completions,
- initial_position: position,
- match_candidates: completions
- .iter()
- .enumerate()
- .map(|(id, completion)| {
- StringMatchCandidate::new(
- id,
- completion.label.text[completion.label.filter_range.clone()]
- .into(),
- )
- })
- .collect(),
- buffer: buffer.clone(),
- completions: Arc::new(RwLock::new(completions.into())),
- matches: Vec::new().into(),
- selected_item: 0,
- scroll_handle: UniformListScrollHandle::new(),
- selected_completion_documentation_resolve_debounce: Arc::new(Mutex::new(
- DebouncedDelay::new(),
- )),
- };
+ position,
+ buffer.clone(),
+ completions.into(),
+ );
menu.filter(query.as_deref(), cx.background_executor().clone())
.await;
@@ -4657,7 +4740,11 @@ impl Editor {
self.transact(cx, |this, cx| {
if let Some(mut snippet) = snippet {
snippet.text = text.to_string();
- for tabstop in snippet.tabstops.iter_mut().flatten() {
+ for tabstop in snippet
+ .tabstops
+ .iter_mut()
+ .flat_map(|tabstop| tabstop.ranges.iter_mut())
+ {
tabstop.start -= common_prefix_len as isize;
tabstop.end -= common_prefix_len as isize;
}
@@ -5693,6 +5780,27 @@ impl Editor {
context_menu
}
+ fn show_snippet_choices(
+ &mut self,
+ choices: &Vec<String>,
+ selection: Range<Anchor>,
+ cx: &mut ViewContext<Self>,
+ ) {
+ if selection.start.buffer_id.is_none() {
+ return;
+ }
+ let buffer_id = selection.start.buffer_id.unwrap();
+ let buffer = self.buffer().read(cx).buffer(buffer_id);
+ let id = post_inc(&mut self.next_completion_id);
+
+ if let Some(buffer) = buffer {
+ *self.context_menu.write() = Some(ContextMenu::Completions(
+ CompletionsMenu::new_snippet_choices(id, true, choices, selection, buffer)
+ .suppress_documentation_resolution(),
+ ));
+ }
+ }
+
pub fn insert_snippet(
&mut self,
insertion_ranges: &[Range<usize>],
@@ -5702,6 +5810,7 @@ impl Editor {
struct Tabstop<T> {
is_end_tabstop: bool,
ranges: Vec<Range<T>>,
+ choices: Option<Vec<String>>,
}
let tabstops = self.buffer.update(cx, |buffer, cx| {
@@ -5721,10 +5830,11 @@ impl Editor {
.tabstops
.iter()
.map(|tabstop| {
- let is_end_tabstop = tabstop.first().map_or(false, |tabstop| {
+ let is_end_tabstop = tabstop.ranges.first().map_or(false, |tabstop| {
tabstop.is_empty() && tabstop.start == snippet.text.len() as isize
});
let mut tabstop_ranges = tabstop
+ .ranges
.iter()
.flat_map(|tabstop_range| {
let mut delta = 0_isize;
@@ -5746,6 +5856,7 @@ impl Editor {
Tabstop {
is_end_tabstop,
ranges: tabstop_ranges,
+ choices: tabstop.choices.clone(),
}
})
.collect::<Vec<_>>()
@@ -5755,16 +5866,29 @@ impl Editor {
s.select_ranges(tabstop.ranges.iter().cloned());
});
+ if let Some(choices) = &tabstop.choices {
+ if let Some(selection) = tabstop.ranges.first() {
+ self.show_snippet_choices(choices, selection.clone(), cx)
+ }
+ }
+
// If we're already at the last tabstop and it's at the end of the snippet,
// we're done, we don't need to keep the state around.
if !tabstop.is_end_tabstop {
+ let choices = tabstops
+ .iter()
+ .map(|tabstop| tabstop.choices.clone())
+ .collect();
+
let ranges = tabstops
.into_iter()
.map(|tabstop| tabstop.ranges)
.collect::<Vec<_>>();
+
self.snippet_stack.push(SnippetState {
active_index: 0,
ranges,
+ choices,
});
}
@@ -5839,6 +5963,13 @@ impl Editor {
self.change_selections(Some(Autoscroll::fit()), cx, |s| {
s.select_anchor_ranges(current_ranges.iter().cloned())
});
+
+ if let Some(choices) = &snippet.choices[snippet.active_index] {
+ if let Some(selection) = current_ranges.first() {
+ self.show_snippet_choices(&choices, selection.clone(), cx);
+ }
+ }
+
// If snippet state is not at the last tabstop, push it back on the stack
if snippet.active_index + 1 < snippet.ranges.len() {
self.snippet_stack.push(snippet);
@@ -6551,6 +6551,45 @@ async fn test_auto_replace_emoji_shortcode(cx: &mut gpui::TestAppContext) {
});
}
+#[gpui::test]
+async fn test_snippet_placeholder_choices(cx: &mut gpui::TestAppContext) {
+ init_test(cx, |_| {});
+
+ let (text, insertion_ranges) = marked_text_ranges(
+ indoc! {"
+ ห
+ "},
+ false,
+ );
+
+ let buffer = cx.update(|cx| MultiBuffer::build_simple(&text, cx));
+ let (editor, cx) = cx.add_window_view(|cx| build_editor(buffer, cx));
+
+ _ = editor.update(cx, |editor, cx| {
+ let snippet = Snippet::parse("type ${1|,i32,u32|} = $2").unwrap();
+
+ editor
+ .insert_snippet(&insertion_ranges, snippet, cx)
+ .unwrap();
+
+ fn assert(editor: &mut Editor, cx: &mut ViewContext<Editor>, marked_text: &str) {
+ let (expected_text, selection_ranges) = marked_text_ranges(marked_text, false);
+ assert_eq!(editor.text(cx), expected_text);
+ assert_eq!(editor.selections.ranges::<usize>(cx), selection_ranges);
+ }
+
+ assert(
+ editor,
+ cx,
+ indoc! {"
+ type ยซยป =โข
+ "},
+ );
+
+ assert!(editor.context_menu_visible(), "There should be a matches");
+ });
+}
+
#[gpui::test]
async fn test_snippets(cx: &mut gpui::TestAppContext) {
init_test(cx, |_| {});
@@ -8,7 +8,11 @@ pub struct Snippet {
pub tabstops: Vec<TabStop>,
}
-type TabStop = SmallVec<[Range<isize>; 2]>;
+#[derive(Clone, Debug, Default, PartialEq)]
+pub struct TabStop {
+ pub ranges: SmallVec<[Range<isize>; 2]>,
+ pub choices: Option<Vec<String>>,
+}
impl Snippet {
pub fn parse(source: &str) -> Result<Self> {
@@ -24,7 +28,11 @@ impl Snippet {
if let Some(final_tabstop) = final_tabstop {
tabstops.push(final_tabstop);
} else {
- let end_tabstop = [len..len].into_iter().collect();
+ let end_tabstop = TabStop {
+ ranges: [len..len].into_iter().collect(),
+ choices: None,
+ };
+
if !tabstops.last().map_or(false, |t| *t == end_tabstop) {
tabstops.push(end_tabstop);
}
@@ -88,11 +96,17 @@ fn parse_tabstop<'a>(
) -> Result<&'a str> {
let tabstop_start = text.len();
let tabstop_index;
+ let mut choices = None;
+
if source.starts_with('{') {
let (index, rest) = parse_int(&source[1..])?;
tabstop_index = index;
source = rest;
+ if source.starts_with("|") {
+ (source, choices) = parse_choices(&source[1..], text)?;
+ }
+
if source.starts_with(':') {
source = parse_snippet(&source[1..], true, text, tabstops)?;
}
@@ -110,7 +124,11 @@ fn parse_tabstop<'a>(
tabstops
.entry(tabstop_index)
- .or_default()
+ .or_insert_with(|| TabStop {
+ ranges: Default::default(),
+ choices,
+ })
+ .ranges
.push(tabstop_start as isize..text.len() as isize);
Ok(source)
}
@@ -126,6 +144,61 @@ fn parse_int(source: &str) -> Result<(usize, &str)> {
Ok((prefix.parse()?, suffix))
}
+fn parse_choices<'a>(
+ mut source: &'a str,
+ text: &mut String,
+) -> Result<(&'a str, Option<Vec<String>>)> {
+ let mut found_default_choice = false;
+ let mut current_choice = String::new();
+ let mut choices = Vec::new();
+
+ loop {
+ match source.chars().next() {
+ None => return Ok(("", Some(choices))),
+ Some('\\') => {
+ source = &source[1..];
+
+ if let Some(c) = source.chars().next() {
+ if !found_default_choice {
+ current_choice.push(c);
+ text.push(c);
+ }
+ source = &source[c.len_utf8()..];
+ }
+ }
+ Some(',') => {
+ found_default_choice = true;
+ source = &source[1..];
+ choices.push(current_choice);
+ current_choice = String::new();
+ }
+ Some('|') => {
+ source = &source[1..];
+ choices.push(current_choice);
+ return Ok((source, Some(choices)));
+ }
+ Some(_) => {
+ let chunk_end = source.find([',', '|', '\\']);
+
+ if chunk_end.is_none() {
+ return Err(anyhow!(
+ "Placeholder choice doesn't contain closing pipe-character '|'"
+ ));
+ }
+
+ let (chunk, rest) = source.split_at(chunk_end.unwrap());
+
+ if !found_default_choice {
+ text.push_str(chunk);
+ }
+
+ current_choice.push_str(chunk);
+ source = rest;
+ }
+ }
+ }
+}
+
#[cfg(test)]
mod tests {
use super::*;
@@ -142,11 +215,13 @@ mod tests {
let snippet = Snippet::parse("one$1two").unwrap();
assert_eq!(snippet.text, "onetwo");
assert_eq!(tabstops(&snippet), &[vec![3..3], vec![6..6]]);
+ assert_eq!(tabstop_choices(&snippet), &[&None, &None]);
// Multi-digit numbers
let snippet = Snippet::parse("one$123-$99-two").unwrap();
assert_eq!(snippet.text, "one--two");
assert_eq!(tabstops(&snippet), &[vec![4..4], vec![3..3], vec![8..8]]);
+ assert_eq!(tabstop_choices(&snippet), &[&None, &None, &None]);
}
#[test]
@@ -157,6 +232,7 @@ mod tests {
// an additional tabstop at the end.
assert_eq!(snippet.text, r#"foo."#);
assert_eq!(tabstops(&snippet), &[vec![4..4]]);
+ assert_eq!(tabstop_choices(&snippet), &[&None]);
}
#[test]
@@ -167,6 +243,7 @@ mod tests {
// don't insert an additional tabstop at the end.
assert_eq!(snippet.text, r#"<div class=""></div>"#);
assert_eq!(tabstops(&snippet), &[vec![12..12], vec![14..14]]);
+ assert_eq!(tabstop_choices(&snippet), &[&None, &None]);
}
#[test]
@@ -177,6 +254,30 @@ mod tests {
tabstops(&snippet),
&[vec![3..6], vec![11..15], vec![15..15]]
);
+ assert_eq!(tabstop_choices(&snippet), &[&None, &None, &None]);
+ }
+
+ #[test]
+ fn test_snippet_with_choice_placeholders() {
+ let snippet = Snippet::parse("type ${1|i32, u32|} = $2")
+ .expect("Should be able to unpack choice placeholders");
+
+ assert_eq!(snippet.text, "type i32 = ");
+ assert_eq!(tabstops(&snippet), &[vec![5..8], vec![11..11],]);
+ assert_eq!(
+ tabstop_choices(&snippet),
+ &[&Some(vec!["i32".to_string(), " u32".to_string()]), &None]
+ );
+
+ let snippet = Snippet::parse(r"${1|\$\{1\|one\,two\,tree\|\}|}")
+ .expect("Should be able to parse choice with escape characters");
+
+ assert_eq!(snippet.text, "${1|one,two,tree|}");
+ assert_eq!(tabstops(&snippet), &[vec![0..18], vec![18..18]]);
+ assert_eq!(
+ tabstop_choices(&snippet),
+ &[&Some(vec!["${1|one,two,tree|}".to_string(),]), &None]
+ );
}
#[test]
@@ -196,6 +297,10 @@ mod tests {
vec![40..40],
]
);
+ assert_eq!(
+ tabstop_choices(&snippet),
+ &[&None, &None, &None, &None, &None]
+ );
}
#[test]
@@ -203,10 +308,12 @@ mod tests {
let snippet = Snippet::parse("\"\\$schema\": $1").unwrap();
assert_eq!(snippet.text, "\"$schema\": ");
assert_eq!(tabstops(&snippet), &[vec![11..11]]);
+ assert_eq!(tabstop_choices(&snippet), &[&None]);
let snippet = Snippet::parse("{a\\}").unwrap();
assert_eq!(snippet.text, "{a}");
assert_eq!(tabstops(&snippet), &[vec![3..3]]);
+ assert_eq!(tabstop_choices(&snippet), &[&None]);
// backslash not functioning as an escape
let snippet = Snippet::parse("a\\b").unwrap();
@@ -221,6 +328,10 @@ mod tests {
}
fn tabstops(snippet: &Snippet) -> Vec<Vec<Range<isize>>> {
- snippet.tabstops.iter().map(|t| t.to_vec()).collect()
+ snippet.tabstops.iter().map(|t| t.ranges.to_vec()).collect()
+ }
+
+ fn tabstop_choices(snippet: &Snippet) -> Vec<&Option<Vec<String>>> {
+ snippet.tabstops.iter().map(|t| &t.choices).collect()
}
}