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}