diff --git a/crates/editor/src/debounced_delay.rs b/crates/editor/src/debounced_delay.rs index 0dbf36d49e38aac439117ea598f2eba29283a47d..ad4b55b20948bafd8da3a1f3285142b233bfa362 100644 --- a/crates/editor/src/debounced_delay.rs +++ b/crates/editor/src/debounced_delay.rs @@ -5,6 +5,7 @@ use gpui::{Task, ViewContext}; use crate::Editor; +#[derive(Debug)] pub struct DebouncedDelay { task: Option>, cancel_channel: Option>, diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 9d8044f075eb9f1645f1afacc91b0b407666f62a..11d47daa6b4dcc452602aef05a464ddc56131e39 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -883,6 +883,7 @@ struct AutocloseRegion { struct SnippetState { ranges: Vec>>, active_index: usize, + choices: Vec>>, } #[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>, + selected_completion_documentation_resolve_debounce: Option>>, } impl CompletionsMenu { + fn new( + id: CompletionId, + sort_completions: bool, + initial_position: Anchor, + buffer: Model, + 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, + selection: Range, + buffer: Model, + ) -> 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, + selection: Range, + cx: &mut ViewContext, + ) { + 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], @@ -5702,6 +5810,7 @@ impl Editor { struct Tabstop { is_end_tabstop: bool, ranges: Vec>, + choices: Option>, } 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::>() @@ -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::>(); + 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); diff --git a/crates/editor/src/editor_tests.rs b/crates/editor/src/editor_tests.rs index 4469b2e614cbc35a9f7044d654b4abef0fc8584b..2e4edf98bc07872ddb8c4b41d1ec3326e93f66ec 100644 --- a/crates/editor/src/editor_tests.rs +++ b/crates/editor/src/editor_tests.rs @@ -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, 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::(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, |_| {}); diff --git a/crates/snippet/src/snippet.rs b/crates/snippet/src/snippet.rs index 41529939a1a1eed3eaf432145d550f36ce8c7ee4..3eeaff285ee300b684502d7d5142cf5b32831e87 100644 --- a/crates/snippet/src/snippet.rs +++ b/crates/snippet/src/snippet.rs @@ -8,7 +8,11 @@ pub struct Snippet { pub tabstops: Vec, } -type TabStop = SmallVec<[Range; 2]>; +#[derive(Clone, Debug, Default, PartialEq)] +pub struct TabStop { + pub ranges: SmallVec<[Range; 2]>, + pub choices: Option>, +} impl Snippet { pub fn parse(source: &str) -> Result { @@ -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>)> { + 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#"
"#); 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>> { - 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>> { + snippet.tabstops.iter().map(|t| &t.choices).collect() } }