1use anyhow::{Context as _, Result, anyhow};
2use assistant_slash_command::{
3 AfterCompletion, ArgumentCompletion, SlashCommand, SlashCommandContent, SlashCommandEvent,
4 SlashCommandOutput, SlashCommandOutputSection, SlashCommandResult,
5};
6use futures::Stream;
7use futures::channel::mpsc;
8use fuzzy::PathMatch;
9use gpui::{App, Entity, Task, WeakEntity};
10use language::{BufferSnapshot, CodeLabelBuilder, HighlightId, LineEnding, LspAdapterDelegate};
11use project::{PathMatchCandidateSet, Project};
12use serde::{Deserialize, Serialize};
13use smol::stream::StreamExt;
14use std::{
15 fmt::Write,
16 ops::{Range, RangeInclusive},
17 path::Path,
18 sync::{Arc, atomic::AtomicBool},
19};
20use ui::prelude::*;
21use util::{ResultExt, rel_path::RelPath};
22use workspace::Workspace;
23use worktree::ChildEntriesOptions;
24
25pub struct FileSlashCommand;
26
27impl FileSlashCommand {
28 fn search_paths(
29 &self,
30 query: String,
31 cancellation_flag: Arc<AtomicBool>,
32 workspace: &Entity<Workspace>,
33 cx: &mut App,
34 ) -> Task<Vec<PathMatch>> {
35 if query.is_empty() {
36 let workspace = workspace.read(cx);
37 let project = workspace.project().read(cx);
38 let entries = workspace.recent_navigation_history(Some(10), cx);
39
40 let entries = entries
41 .into_iter()
42 .map(|entries| (entries.0, false))
43 .chain(project.worktrees(cx).flat_map(|worktree| {
44 let worktree = worktree.read(cx);
45 let id = worktree.id();
46 let options = ChildEntriesOptions {
47 include_files: true,
48 include_dirs: true,
49 include_ignored: false,
50 };
51 let entries = worktree.child_entries_with_options(RelPath::empty(), options);
52 entries.map(move |entry| {
53 (
54 project::ProjectPath {
55 worktree_id: id,
56 path: entry.path.clone(),
57 },
58 entry.kind.is_dir(),
59 )
60 })
61 }))
62 .collect::<Vec<_>>();
63
64 let path_prefix: Arc<RelPath> = RelPath::empty().into();
65 Task::ready(
66 entries
67 .into_iter()
68 .filter_map(|(entry, is_dir)| {
69 let worktree = project.worktree_for_id(entry.worktree_id, cx)?;
70 let full_path = worktree.read(cx).root_name().join(&entry.path);
71 Some(PathMatch {
72 score: 0.,
73 positions: Vec::new(),
74 worktree_id: entry.worktree_id.to_usize(),
75 path: full_path,
76 path_prefix: path_prefix.clone(),
77 distance_to_relative_ancestor: 0,
78 is_dir,
79 })
80 })
81 .collect(),
82 )
83 } else {
84 let worktrees = workspace.read(cx).visible_worktrees(cx).collect::<Vec<_>>();
85 let candidate_sets = worktrees
86 .into_iter()
87 .map(|worktree| {
88 let worktree = worktree.read(cx);
89
90 PathMatchCandidateSet {
91 snapshot: worktree.snapshot(),
92 include_ignored: worktree
93 .root_entry()
94 .is_some_and(|entry| entry.is_ignored),
95 include_root_name: true,
96 candidates: project::Candidates::Entries,
97 }
98 })
99 .collect::<Vec<_>>();
100
101 let executor = cx.background_executor().clone();
102 cx.foreground_executor().spawn(async move {
103 fuzzy::match_path_sets(
104 candidate_sets.as_slice(),
105 query.as_str(),
106 &None,
107 false,
108 100,
109 &cancellation_flag,
110 executor,
111 )
112 .await
113 })
114 }
115 }
116}
117
118impl SlashCommand for FileSlashCommand {
119 fn name(&self) -> String {
120 "file".into()
121 }
122
123 fn description(&self) -> String {
124 "Insert file and/or directory".into()
125 }
126
127 fn menu_text(&self) -> String {
128 self.description()
129 }
130
131 fn requires_argument(&self) -> bool {
132 true
133 }
134
135 fn icon(&self) -> IconName {
136 IconName::File
137 }
138
139 fn complete_argument(
140 self: Arc<Self>,
141 arguments: &[String],
142 cancellation_flag: Arc<AtomicBool>,
143 workspace: Option<WeakEntity<Workspace>>,
144 _: &mut Window,
145 cx: &mut App,
146 ) -> Task<Result<Vec<ArgumentCompletion>>> {
147 let Some(workspace) = workspace.and_then(|workspace| workspace.upgrade()) else {
148 return Task::ready(Err(anyhow!("workspace was dropped")));
149 };
150
151 let path_style = workspace.read(cx).path_style(cx);
152
153 let paths = self.search_paths(
154 arguments.last().cloned().unwrap_or_default(),
155 cancellation_flag,
156 &workspace,
157 cx,
158 );
159 let comment_id = cx.theme().syntax().highlight_id("comment").map(HighlightId);
160 cx.background_spawn(async move {
161 Ok(paths
162 .await
163 .into_iter()
164 .filter_map(|path_match| {
165 let text = path_match
166 .path_prefix
167 .join(&path_match.path)
168 .display(path_style)
169 .to_string();
170
171 let mut label = CodeLabelBuilder::default();
172 let file_name = path_match.path.file_name()?;
173 let label_text = if path_match.is_dir {
174 format!("{}/ ", file_name)
175 } else {
176 format!("{} ", file_name)
177 };
178
179 label.push_str(label_text.as_str(), None);
180 label.push_str(&text, comment_id);
181 label.respan_filter_range(Some(file_name));
182
183 Some(ArgumentCompletion {
184 label: label.build(),
185 new_text: text,
186 after_completion: AfterCompletion::Compose,
187 replace_previous_arguments: false,
188 })
189 })
190 .collect())
191 })
192 }
193
194 fn run(
195 self: Arc<Self>,
196 arguments: &[String],
197 _context_slash_command_output_sections: &[SlashCommandOutputSection<language::Anchor>],
198 _context_buffer: BufferSnapshot,
199 workspace: WeakEntity<Workspace>,
200 _delegate: Option<Arc<dyn LspAdapterDelegate>>,
201 _: &mut Window,
202 cx: &mut App,
203 ) -> Task<SlashCommandResult> {
204 let Some(workspace) = workspace.upgrade() else {
205 return Task::ready(Err(anyhow!("workspace was dropped")));
206 };
207
208 if arguments.is_empty() {
209 return Task::ready(Err(anyhow!("missing path")));
210 };
211
212 Task::ready(Ok(collect_files(
213 workspace.read(cx).project().clone(),
214 arguments,
215 cx,
216 )
217 .boxed()))
218 }
219}
220
221fn collect_files(
222 project: Entity<Project>,
223 glob_inputs: &[String],
224 cx: &mut App,
225) -> impl Stream<Item = Result<SlashCommandEvent>> + use<> {
226 let Ok(matchers) = glob_inputs
227 .iter()
228 .map(|glob_input| {
229 util::paths::PathMatcher::new(&[glob_input.to_owned()], project.read(cx).path_style(cx))
230 .with_context(|| format!("invalid path {glob_input}"))
231 })
232 .collect::<anyhow::Result<Vec<util::paths::PathMatcher>>>()
233 else {
234 return futures::stream::once(async {
235 anyhow::bail!("invalid path");
236 })
237 .boxed();
238 };
239
240 let project_handle = project.downgrade();
241 let snapshots = project
242 .read(cx)
243 .worktrees(cx)
244 .map(|worktree| worktree.read(cx).snapshot())
245 .collect::<Vec<_>>();
246
247 let (events_tx, events_rx) = mpsc::unbounded();
248 cx.spawn(async move |cx| {
249 for snapshot in snapshots {
250 let worktree_id = snapshot.id();
251 let path_style = snapshot.path_style();
252 let mut directory_stack: Vec<Arc<RelPath>> = Vec::new();
253 let mut folded_directory_path: Option<Arc<RelPath>> = None;
254 let mut folded_directory_names: Arc<RelPath> = RelPath::empty().into();
255 let mut is_top_level_directory = true;
256
257 for entry in snapshot.entries(false, 0) {
258 let path_including_worktree_name = snapshot.root_name().join(&entry.path);
259
260 if !matchers
261 .iter()
262 .any(|matcher| matcher.is_match(&path_including_worktree_name))
263 {
264 continue;
265 }
266
267 while let Some(dir) = directory_stack.last() {
268 if entry.path.starts_with(dir) {
269 break;
270 }
271 directory_stack.pop().unwrap();
272 events_tx.unbounded_send(Ok(SlashCommandEvent::EndSection))?;
273 events_tx.unbounded_send(Ok(SlashCommandEvent::Content(
274 SlashCommandContent::Text {
275 text: "\n".into(),
276 run_commands_in_text: false,
277 },
278 )))?;
279 }
280
281 if let Some(folded_path) = &folded_directory_path {
282 if !entry.path.starts_with(folded_path) {
283 folded_directory_names = RelPath::empty().into();
284 folded_directory_path = None;
285 if directory_stack.is_empty() {
286 is_top_level_directory = true;
287 }
288 }
289 }
290
291 let filename = entry.path.file_name().unwrap_or_default().to_string();
292
293 if entry.is_dir() {
294 // Auto-fold directories that contain no files
295 let mut child_entries = snapshot.child_entries(&entry.path);
296 if let Some(child) = child_entries.next() {
297 if child_entries.next().is_none() && child.kind.is_dir() {
298 if is_top_level_directory {
299 is_top_level_directory = false;
300 folded_directory_names =
301 folded_directory_names.join(&path_including_worktree_name);
302 } else {
303 folded_directory_names =
304 folded_directory_names.join(RelPath::unix(&filename).unwrap());
305 }
306 folded_directory_path = Some(entry.path.clone());
307 continue;
308 }
309 } else {
310 // Skip empty directories
311 folded_directory_names = RelPath::empty().into();
312 folded_directory_path = None;
313 continue;
314 }
315
316 // Render the directory (either folded or normal)
317 if folded_directory_names.is_empty() {
318 let label = if is_top_level_directory {
319 is_top_level_directory = false;
320 path_including_worktree_name.display(path_style).to_string()
321 } else {
322 filename
323 };
324 events_tx.unbounded_send(Ok(SlashCommandEvent::StartSection {
325 icon: IconName::Folder,
326 label: label.clone().into(),
327 metadata: None,
328 }))?;
329 events_tx.unbounded_send(Ok(SlashCommandEvent::Content(
330 SlashCommandContent::Text {
331 text: label.to_string(),
332 run_commands_in_text: false,
333 },
334 )))?;
335 directory_stack.push(entry.path.clone());
336 } else {
337 let entry_name =
338 folded_directory_names.join(RelPath::unix(&filename).unwrap());
339 let entry_name = entry_name.display(path_style);
340 events_tx.unbounded_send(Ok(SlashCommandEvent::StartSection {
341 icon: IconName::Folder,
342 label: entry_name.to_string().into(),
343 metadata: None,
344 }))?;
345 events_tx.unbounded_send(Ok(SlashCommandEvent::Content(
346 SlashCommandContent::Text {
347 text: entry_name.to_string(),
348 run_commands_in_text: false,
349 },
350 )))?;
351 directory_stack.push(entry.path.clone());
352 folded_directory_names = RelPath::empty().into();
353 folded_directory_path = None;
354 }
355 events_tx.unbounded_send(Ok(SlashCommandEvent::Content(
356 SlashCommandContent::Text {
357 text: "\n".into(),
358 run_commands_in_text: false,
359 },
360 )))?;
361 } else if entry.is_file() {
362 let Some(open_buffer_task) = project_handle
363 .update(cx, |project, cx| {
364 project.open_buffer((worktree_id, entry.path.clone()), cx)
365 })
366 .ok()
367 else {
368 continue;
369 };
370 if let Some(buffer) = open_buffer_task.await.log_err() {
371 let mut output = SlashCommandOutput::default();
372 let snapshot = buffer.read_with(cx, |buffer, _| buffer.snapshot())?;
373 append_buffer_to_output(
374 &snapshot,
375 Some(path_including_worktree_name.display(path_style).as_ref()),
376 &mut output,
377 )
378 .log_err();
379 let mut buffer_events = output.into_event_stream();
380 while let Some(event) = buffer_events.next().await {
381 events_tx.unbounded_send(event)?;
382 }
383 }
384 }
385 }
386
387 while directory_stack.pop().is_some() {
388 events_tx.unbounded_send(Ok(SlashCommandEvent::EndSection))?;
389 }
390 }
391
392 anyhow::Ok(())
393 })
394 .detach_and_log_err(cx);
395
396 events_rx.boxed()
397}
398
399pub fn codeblock_fence_for_path(
400 path: Option<&str>,
401 row_range: Option<RangeInclusive<u32>>,
402) -> String {
403 let mut text = String::new();
404 write!(text, "```").unwrap();
405
406 if let Some(path) = path {
407 if let Some(extension) = Path::new(path).extension().and_then(|ext| ext.to_str()) {
408 write!(text, "{} ", extension).unwrap();
409 }
410
411 write!(text, "{path}").unwrap();
412 } else {
413 write!(text, "untitled").unwrap();
414 }
415
416 if let Some(row_range) = row_range {
417 write!(text, ":{}-{}", row_range.start() + 1, row_range.end() + 1).unwrap();
418 }
419
420 text.push('\n');
421 text
422}
423
424#[derive(Serialize, Deserialize)]
425pub struct FileCommandMetadata {
426 pub path: String,
427}
428
429pub fn build_entry_output_section(
430 range: Range<usize>,
431 path: Option<&str>,
432 is_directory: bool,
433 line_range: Option<Range<u32>>,
434) -> SlashCommandOutputSection<usize> {
435 let mut label = if let Some(path) = path {
436 path.to_string()
437 } else {
438 "untitled".to_string()
439 };
440 if let Some(line_range) = line_range {
441 write!(label, ":{}-{}", line_range.start, line_range.end).unwrap();
442 }
443
444 let icon = if is_directory {
445 IconName::Folder
446 } else {
447 IconName::File
448 };
449
450 SlashCommandOutputSection {
451 range,
452 icon,
453 label: label.into(),
454 metadata: if is_directory {
455 None
456 } else {
457 path.and_then(|path| {
458 serde_json::to_value(FileCommandMetadata {
459 path: path.to_string(),
460 })
461 .ok()
462 })
463 },
464 }
465}
466
467pub fn append_buffer_to_output(
468 buffer: &BufferSnapshot,
469 path: Option<&str>,
470 output: &mut SlashCommandOutput,
471) -> Result<()> {
472 let prev_len = output.text.len();
473
474 let mut content = buffer.text();
475 LineEnding::normalize(&mut content);
476 output.text.push_str(&codeblock_fence_for_path(path, None));
477 output.text.push_str(&content);
478 if !output.text.ends_with('\n') {
479 output.text.push('\n');
480 }
481 output.text.push_str("```");
482 output.text.push('\n');
483
484 let section_ix = output.sections.len();
485 output.sections.insert(
486 section_ix,
487 build_entry_output_section(prev_len..output.text.len(), path, false, None),
488 );
489
490 output.text.push('\n');
491
492 Ok(())
493}
494
495#[cfg(test)]
496mod test {
497 use assistant_slash_command::SlashCommandOutput;
498 use fs::FakeFs;
499 use gpui::TestAppContext;
500 use pretty_assertions::assert_eq;
501 use project::Project;
502 use serde_json::json;
503 use settings::SettingsStore;
504 use smol::stream::StreamExt;
505 use util::path;
506
507 use super::collect_files;
508
509 pub fn init_test(cx: &mut gpui::TestAppContext) {
510 zlog::init_test();
511
512 cx.update(|cx| {
513 let settings_store = SettingsStore::test(cx);
514 cx.set_global(settings_store);
515 // release_channel::init(SemanticVersion::default(), cx);
516 });
517 }
518
519 #[gpui::test]
520 async fn test_file_exact_matching(cx: &mut TestAppContext) {
521 init_test(cx);
522 let fs = FakeFs::new(cx.executor());
523
524 fs.insert_tree(
525 path!("/root"),
526 json!({
527 "dir": {
528 "subdir": {
529 "file_0": "0"
530 },
531 "file_1": "1",
532 "file_2": "2",
533 "file_3": "3",
534 },
535 "dir.rs": "4"
536 }),
537 )
538 .await;
539
540 let project = Project::test(fs, [path!("/root").as_ref()], cx).await;
541
542 let result_1 =
543 cx.update(|cx| collect_files(project.clone(), &["root/dir".to_string()], cx));
544 let result_1 = SlashCommandOutput::from_event_stream(result_1.boxed())
545 .await
546 .unwrap();
547
548 assert!(result_1.text.starts_with(path!("root/dir")));
549 // 4 files + 2 directories
550 assert_eq!(result_1.sections.len(), 6);
551
552 let result_2 =
553 cx.update(|cx| collect_files(project.clone(), &["root/dir/".to_string()], cx));
554 let result_2 = SlashCommandOutput::from_event_stream(result_2.boxed())
555 .await
556 .unwrap();
557
558 assert_eq!(result_1, result_2);
559
560 let result =
561 cx.update(|cx| collect_files(project.clone(), &["root/dir*".to_string()], cx).boxed());
562 let result = SlashCommandOutput::from_event_stream(result).await.unwrap();
563
564 assert!(result.text.starts_with(path!("root/dir")));
565 // 5 files + 2 directories
566 assert_eq!(result.sections.len(), 7);
567
568 // Ensure that the project lasts until after the last await
569 drop(project);
570 }
571
572 #[gpui::test]
573 async fn test_file_sub_directory_rendering(cx: &mut TestAppContext) {
574 init_test(cx);
575 let fs = FakeFs::new(cx.executor());
576
577 fs.insert_tree(
578 path!("/zed"),
579 json!({
580 "assets": {
581 "dir1": {
582 ".gitkeep": ""
583 },
584 "dir2": {
585 ".gitkeep": ""
586 },
587 "themes": {
588 "ayu": {
589 "LICENSE": "1",
590 },
591 "andromeda": {
592 "LICENSE": "2",
593 },
594 "summercamp": {
595 "LICENSE": "3",
596 },
597 },
598 },
599 }),
600 )
601 .await;
602
603 let project = Project::test(fs, [path!("/zed").as_ref()], cx).await;
604
605 let result =
606 cx.update(|cx| collect_files(project.clone(), &["zed/assets/themes".to_string()], cx));
607 let result = SlashCommandOutput::from_event_stream(result.boxed())
608 .await
609 .unwrap();
610
611 // Sanity check
612 assert!(result.text.starts_with(path!("zed/assets/themes\n")));
613 assert_eq!(result.sections.len(), 7);
614
615 // Ensure that full file paths are included in the real output
616 assert!(
617 result
618 .text
619 .contains(path!("zed/assets/themes/andromeda/LICENSE"))
620 );
621 assert!(result.text.contains(path!("zed/assets/themes/ayu/LICENSE")));
622 assert!(
623 result
624 .text
625 .contains(path!("zed/assets/themes/summercamp/LICENSE"))
626 );
627
628 assert_eq!(result.sections[5].label, "summercamp");
629
630 // Ensure that things are in descending order, with properly relativized paths
631 assert_eq!(
632 result.sections[0].label,
633 path!("zed/assets/themes/andromeda/LICENSE")
634 );
635 assert_eq!(result.sections[1].label, "andromeda");
636 assert_eq!(
637 result.sections[2].label,
638 path!("zed/assets/themes/ayu/LICENSE")
639 );
640 assert_eq!(result.sections[3].label, "ayu");
641 assert_eq!(
642 result.sections[4].label,
643 path!("zed/assets/themes/summercamp/LICENSE")
644 );
645
646 // Ensure that the project lasts until after the last await
647 drop(project);
648 }
649
650 #[gpui::test]
651 async fn test_file_deep_sub_directory_rendering(cx: &mut TestAppContext) {
652 init_test(cx);
653 let fs = FakeFs::new(cx.executor());
654
655 fs.insert_tree(
656 path!("/zed"),
657 json!({
658 "assets": {
659 "themes": {
660 "LICENSE": "1",
661 "summercamp": {
662 "LICENSE": "1",
663 "subdir": {
664 "LICENSE": "1",
665 "subsubdir": {
666 "LICENSE": "3",
667 }
668 }
669 },
670 },
671 },
672 }),
673 )
674 .await;
675
676 let project = Project::test(fs, [path!("/zed").as_ref()], cx).await;
677
678 let result =
679 cx.update(|cx| collect_files(project.clone(), &["zed/assets/themes".to_string()], cx));
680 let result = SlashCommandOutput::from_event_stream(result.boxed())
681 .await
682 .unwrap();
683
684 assert!(result.text.starts_with(path!("zed/assets/themes\n")));
685 assert_eq!(result.sections[0].label, path!("zed/assets/themes/LICENSE"));
686 assert_eq!(
687 result.sections[1].label,
688 path!("zed/assets/themes/summercamp/LICENSE")
689 );
690 assert_eq!(
691 result.sections[2].label,
692 path!("zed/assets/themes/summercamp/subdir/LICENSE")
693 );
694 assert_eq!(
695 result.sections[3].label,
696 path!("zed/assets/themes/summercamp/subdir/subsubdir/LICENSE")
697 );
698 assert_eq!(result.sections[4].label, "subsubdir");
699 assert_eq!(result.sections[5].label, "subdir");
700 assert_eq!(result.sections[6].label, "summercamp");
701 assert_eq!(result.sections[7].label, path!("zed/assets/themes"));
702
703 assert_eq!(
704 result.text,
705 path!(
706 "zed/assets/themes\n```zed/assets/themes/LICENSE\n1\n```\n\nsummercamp\n```zed/assets/themes/summercamp/LICENSE\n1\n```\n\nsubdir\n```zed/assets/themes/summercamp/subdir/LICENSE\n1\n```\n\nsubsubdir\n```zed/assets/themes/summercamp/subdir/subsubdir/LICENSE\n3\n```\n\n"
707 )
708 );
709
710 // Ensure that the project lasts until after the last await
711 drop(project);
712 }
713}