selection_command.rs

  1use anyhow::{Result, anyhow};
  2use assistant_slash_command::{
  3    ArgumentCompletion, SlashCommand, SlashCommandContent, SlashCommandEvent,
  4    SlashCommandOutputSection, SlashCommandResult,
  5};
  6use editor::{BufferOffset, Editor, MultiBufferSnapshot};
  7use futures::StreamExt;
  8use gpui::{App, SharedString, Task, WeakEntity, Window};
  9use language::{BufferSnapshot, CodeLabel, LspAdapterDelegate};
 10
 11use rope::Point;
 12use std::ops::Range;
 13use std::sync::Arc;
 14use std::sync::atomic::AtomicBool;
 15use ui::IconName;
 16use workspace::Workspace;
 17
 18use crate::file_command::codeblock_fence_for_path;
 19
 20pub struct SelectionCommand;
 21
 22impl SlashCommand for SelectionCommand {
 23    fn name(&self) -> String {
 24        "selection".into()
 25    }
 26
 27    fn label(&self, _cx: &App) -> CodeLabel {
 28        CodeLabel::plain(self.name(), None)
 29    }
 30
 31    fn description(&self) -> String {
 32        "Insert editor selection".into()
 33    }
 34
 35    fn icon(&self) -> IconName {
 36        IconName::Quote
 37    }
 38
 39    fn menu_text(&self) -> String {
 40        self.description()
 41    }
 42
 43    fn requires_argument(&self) -> bool {
 44        false
 45    }
 46
 47    fn accepts_arguments(&self) -> bool {
 48        true
 49    }
 50
 51    fn complete_argument(
 52        self: Arc<Self>,
 53        _arguments: &[String],
 54        _cancel: Arc<AtomicBool>,
 55        _workspace: Option<WeakEntity<Workspace>>,
 56        _window: &mut Window,
 57        _cx: &mut App,
 58    ) -> Task<Result<Vec<ArgumentCompletion>>> {
 59        Task::ready(Err(anyhow!("this command does not require argument")))
 60    }
 61
 62    fn run(
 63        self: Arc<Self>,
 64        _arguments: &[String],
 65        _context_slash_command_output_sections: &[SlashCommandOutputSection<language::Anchor>],
 66        _context_buffer: BufferSnapshot,
 67        workspace: WeakEntity<Workspace>,
 68        _delegate: Option<Arc<dyn LspAdapterDelegate>>,
 69        _window: &mut Window,
 70        cx: &mut App,
 71    ) -> Task<SlashCommandResult> {
 72        let mut events = vec![];
 73
 74        let Some(creases) = workspace
 75            .update(cx, |workspace, cx| {
 76                let editor = workspace
 77                    .active_item(cx)
 78                    .and_then(|item| item.act_as::<Editor>(cx))?;
 79
 80                editor.update(cx, |editor, cx| {
 81                    let selection_ranges = editor
 82                        .selections
 83                        .all_adjusted(&editor.display_snapshot(cx))
 84                        .iter()
 85                        .map(|selection| selection.range())
 86                        .collect::<Vec<_>>();
 87                    let snapshot = editor.buffer().read(cx).snapshot(cx);
 88                    Some(selections_creases(selection_ranges, snapshot, cx))
 89                })
 90            })
 91            .unwrap_or_else(|e| {
 92                events.push(Err(e));
 93                None
 94            })
 95        else {
 96            return Task::ready(Err(anyhow!("no active selection")));
 97        };
 98
 99        for (text, title) in creases {
100            events.push(Ok(SlashCommandEvent::StartSection {
101                icon: IconName::TextSnippet,
102                label: SharedString::from(title),
103                metadata: None,
104            }));
105            events.push(Ok(SlashCommandEvent::Content(SlashCommandContent::Text {
106                text,
107                run_commands_in_text: false,
108            })));
109            events.push(Ok(SlashCommandEvent::EndSection));
110            events.push(Ok(SlashCommandEvent::Content(SlashCommandContent::Text {
111                text: "\n".to_string(),
112                run_commands_in_text: false,
113            })));
114        }
115
116        let result = futures::stream::iter(events).boxed();
117
118        Task::ready(Ok(result))
119    }
120}
121
122pub fn selections_creases(
123    selection_ranges: Vec<Range<Point>>,
124    snapshot: MultiBufferSnapshot,
125    cx: &App,
126) -> Vec<(String, String)> {
127    let mut creases = Vec::new();
128    for range in selection_ranges {
129        let buffer_ranges = snapshot.range_to_buffer_ranges(range.clone());
130
131        if buffer_ranges.is_empty() {
132            creases.extend(crease_for_range(range, &snapshot, cx));
133            continue;
134        }
135
136        for (buffer_snapshot, buffer_range, _excerpt_id) in buffer_ranges {
137            creases.extend(crease_for_buffer_range(buffer_snapshot, buffer_range, cx));
138        }
139    }
140    creases
141}
142
143/// Creates a crease for a range within a specific buffer (excerpt).
144/// This is used when we know the exact buffer and range within it.
145fn crease_for_buffer_range(
146    buffer: &BufferSnapshot,
147    Range { start, end }: Range<BufferOffset>,
148    cx: &App,
149) -> Option<(String, String)> {
150    let selected_text: String = buffer.text_for_range(start.0..end.0).collect();
151
152    if selected_text.is_empty() {
153        return None;
154    }
155
156    let start_point = buffer.offset_to_point(start.0);
157    let end_point = buffer.offset_to_point(end.0);
158    let start_buffer_row = start_point.row;
159    let end_buffer_row = end_point.row;
160
161    let language = buffer.language_at(start.0);
162    let language_name_arc = language.map(|l| l.code_fence_block_name());
163    let language_name = language_name_arc.as_deref().unwrap_or_default();
164
165    let filename = buffer
166        .file()
167        .map(|file| file.full_path(cx).to_string_lossy().into_owned());
168
169    let text = if language_name == "markdown" {
170        selected_text
171            .lines()
172            .map(|line| format!("> {}", line))
173            .collect::<Vec<_>>()
174            .join("\n")
175    } else {
176        let start_symbols = buffer.symbols_containing(start, None);
177        let end_symbols = buffer.symbols_containing(end, None);
178
179        let outline_text = if !start_symbols.is_empty() && !end_symbols.is_empty() {
180            Some(
181                start_symbols
182                    .into_iter()
183                    .zip(end_symbols)
184                    .take_while(|(a, b)| a == b)
185                    .map(|(a, _)| a.text)
186                    .collect::<Vec<_>>()
187                    .join(" > "),
188            )
189        } else {
190            None
191        };
192
193        let line_comment_prefix =
194            language.and_then(|l| l.default_scope().line_comment_prefixes().first().cloned());
195
196        let fence =
197            codeblock_fence_for_path(filename.as_deref(), Some(start_buffer_row..=end_buffer_row));
198
199        if let Some((line_comment_prefix, outline_text)) = line_comment_prefix.zip(outline_text) {
200            let breadcrumb = format!("{line_comment_prefix}Excerpt from: {outline_text}\n");
201            format!("{fence}{breadcrumb}{selected_text}\n```")
202        } else {
203            format!("{fence}{selected_text}\n```")
204        }
205    };
206
207    let crease_title = if let Some(path) = filename {
208        let start_line = start_buffer_row + 1;
209        let end_line = end_buffer_row + 1;
210        if start_line == end_line {
211            format!("{path}, Line {start_line}")
212        } else {
213            format!("{path}, Lines {start_line} to {end_line}")
214        }
215    } else {
216        "Quoted selection".to_string()
217    };
218
219    Some((text, crease_title))
220}
221
222/// Fallback function to create a crease from a multibuffer range when we can't split by excerpt.
223fn crease_for_range(
224    range: Range<Point>,
225    snapshot: &MultiBufferSnapshot,
226    cx: &App,
227) -> Option<(String, String)> {
228    let selected_text = snapshot.text_for_range(range.clone()).collect::<String>();
229    if selected_text.is_empty() {
230        return None;
231    }
232
233    // Get actual file line numbers (not multibuffer row numbers)
234    let start_buffer_row = snapshot
235        .point_to_buffer_point(range.start)
236        .map(|(_, point, _)| point.row)
237        .unwrap_or(range.start.row);
238    let end_buffer_row = snapshot
239        .point_to_buffer_point(range.end)
240        .map(|(_, point, _)| point.row)
241        .unwrap_or(range.end.row);
242
243    let start_language = snapshot.language_at(range.start);
244    let end_language = snapshot.language_at(range.end);
245    let language_name = if start_language == end_language {
246        start_language.map(|language| language.code_fence_block_name())
247    } else {
248        None
249    };
250    let language_name = language_name.as_deref().unwrap_or("");
251
252    let filename = snapshot
253        .file_at(range.start)
254        .map(|file| file.full_path(cx).to_string_lossy().into_owned());
255
256    let text = if language_name == "markdown" {
257        selected_text
258            .lines()
259            .map(|line| format!("> {}", line))
260            .collect::<Vec<_>>()
261            .join("\n")
262    } else {
263        let start_symbols = snapshot
264            .symbols_containing(range.start, None)
265            .map(|(_, symbols)| symbols);
266        let end_symbols = snapshot
267            .symbols_containing(range.end, None)
268            .map(|(_, symbols)| symbols);
269
270        let outline_text =
271            if let Some((start_symbols, end_symbols)) = start_symbols.zip(end_symbols) {
272                Some(
273                    start_symbols
274                        .into_iter()
275                        .zip(end_symbols)
276                        .take_while(|(a, b)| a == b)
277                        .map(|(a, _)| a.text)
278                        .collect::<Vec<_>>()
279                        .join(" > "),
280                )
281            } else {
282                None
283            };
284
285        let line_comment_prefix =
286            start_language.and_then(|l| l.default_scope().line_comment_prefixes().first().cloned());
287
288        let fence =
289            codeblock_fence_for_path(filename.as_deref(), Some(start_buffer_row..=end_buffer_row));
290
291        if let Some((line_comment_prefix, outline_text)) = line_comment_prefix.zip(outline_text) {
292            let breadcrumb = format!("{line_comment_prefix}Excerpt from: {outline_text}\n");
293            format!("{fence}{breadcrumb}{selected_text}\n```")
294        } else {
295            format!("{fence}{selected_text}\n```")
296        }
297    };
298
299    let crease_title = if let Some(path) = filename {
300        let start_line = start_buffer_row + 1;
301        let end_line = end_buffer_row + 1;
302        if start_line == end_line {
303            format!("{path}, Line {start_line}")
304        } else {
305            format!("{path}, Lines {start_line} to {end_line}")
306        }
307    } else {
308        "Quoted selection".to_string()
309    };
310
311    Some((text, crease_title))
312}
313
314#[cfg(test)]
315mod tests {
316    use super::*;
317    use gpui::TestAppContext;
318    use multi_buffer::MultiBuffer;
319
320    #[gpui::test]
321    fn test_selections_creases_single_excerpt(cx: &mut TestAppContext) {
322        let buffer = cx.update(|cx| {
323            MultiBuffer::build_multi(
324                [("a\nb\nc\n", vec![Point::new(0, 0)..Point::new(3, 0)])],
325                cx,
326            )
327        });
328        let creases = cx.update(|cx| {
329            let snapshot = buffer.read(cx).snapshot(cx);
330            selections_creases(vec![Point::new(0, 0)..Point::new(2, 1)], snapshot, cx)
331        });
332        assert_eq!(creases.len(), 1);
333        assert_eq!(creases[0].0, "```untitled:1-3\na\nb\nc\n```");
334        assert_eq!(creases[0].1, "Quoted selection");
335    }
336
337    #[gpui::test]
338    fn test_selections_creases_spans_multiple_excerpts(cx: &mut TestAppContext) {
339        let buffer = cx.update(|cx| {
340            MultiBuffer::build_multi(
341                [
342                    ("aaa\nbbb\n", vec![Point::new(0, 0)..Point::new(2, 0)]),
343                    ("111\n222\n", vec![Point::new(0, 0)..Point::new(2, 0)]),
344                ],
345                cx,
346            )
347        });
348        let creases = cx.update(|cx| {
349            let snapshot = buffer.read(cx).snapshot(cx);
350            let end = snapshot.offset_to_point(snapshot.len());
351            selections_creases(vec![Point::new(0, 0)..end], snapshot, cx)
352        });
353        assert_eq!(creases.len(), 2);
354        assert!(creases[0].0.contains("aaa") && !creases[0].0.contains("111"));
355        assert!(creases[1].0.contains("111") && !creases[1].0.contains("aaa"));
356    }
357}