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, CodeLabel, 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 = CodeLabel::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.filter_range = 0..file_name.len();
182
183 Some(ArgumentCompletion {
184 label,
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 custom_path_matcher::PathMatcher::new(&[glob_input.to_owned()])
230 .with_context(|| format!("invalid path {glob_input}"))
231 })
232 .collect::<anyhow::Result<Vec<custom_path_matcher::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_names: Arc<RelPath> = RelPath::empty().into();
254 let mut is_top_level_directory = true;
255
256 for entry in snapshot.entries(false, 0) {
257 let path_including_worktree_name = snapshot.root_name().join(&entry.path);
258
259 if !matchers
260 .iter()
261 .any(|matcher| matcher.is_match(&path_including_worktree_name))
262 {
263 continue;
264 }
265
266 while let Some(dir) = directory_stack.last() {
267 if entry.path.starts_with(dir) {
268 break;
269 }
270 directory_stack.pop().unwrap();
271 events_tx.unbounded_send(Ok(SlashCommandEvent::EndSection))?;
272 events_tx.unbounded_send(Ok(SlashCommandEvent::Content(
273 SlashCommandContent::Text {
274 text: "\n".into(),
275 run_commands_in_text: false,
276 },
277 )))?;
278 }
279
280 let filename = entry.path.file_name().unwrap_or_default().to_string();
281
282 if entry.is_dir() {
283 // Auto-fold directories that contain no files
284 let mut child_entries = snapshot.child_entries(&entry.path);
285 if let Some(child) = child_entries.next() {
286 if child_entries.next().is_none() && child.kind.is_dir() {
287 if is_top_level_directory {
288 is_top_level_directory = false;
289 folded_directory_names =
290 folded_directory_names.join(&path_including_worktree_name);
291 } else {
292 folded_directory_names =
293 folded_directory_names.join(RelPath::new(&filename).unwrap());
294 }
295 continue;
296 }
297 } else {
298 // Skip empty directories
299 folded_directory_names = RelPath::empty().into();
300 continue;
301 }
302 if folded_directory_names.is_empty() {
303 let label = if is_top_level_directory {
304 is_top_level_directory = false;
305 path_including_worktree_name.display(path_style).to_string()
306 } else {
307 filename
308 };
309 events_tx.unbounded_send(Ok(SlashCommandEvent::StartSection {
310 icon: IconName::Folder,
311 label: label.clone().into(),
312 metadata: None,
313 }))?;
314 events_tx.unbounded_send(Ok(SlashCommandEvent::Content(
315 SlashCommandContent::Text {
316 text: label.to_string(),
317 run_commands_in_text: false,
318 },
319 )))?;
320 directory_stack.push(entry.path.clone());
321 } else {
322 let entry_name =
323 folded_directory_names.join(RelPath::new(&filename).unwrap());
324 let entry_name = entry_name.display(path_style);
325 events_tx.unbounded_send(Ok(SlashCommandEvent::StartSection {
326 icon: IconName::Folder,
327 label: entry_name.to_string().into(),
328 metadata: None,
329 }))?;
330 events_tx.unbounded_send(Ok(SlashCommandEvent::Content(
331 SlashCommandContent::Text {
332 text: entry_name.to_string(),
333 run_commands_in_text: false,
334 },
335 )))?;
336 directory_stack.push(entry.path.clone());
337 }
338 events_tx.unbounded_send(Ok(SlashCommandEvent::Content(
339 SlashCommandContent::Text {
340 text: "\n".into(),
341 run_commands_in_text: false,
342 },
343 )))?;
344 } else if entry.is_file() {
345 let Some(open_buffer_task) = project_handle
346 .update(cx, |project, cx| {
347 project.open_buffer((worktree_id, entry.path.clone()), cx)
348 })
349 .ok()
350 else {
351 continue;
352 };
353 if let Some(buffer) = open_buffer_task.await.log_err() {
354 let mut output = SlashCommandOutput::default();
355 let snapshot = buffer.read_with(cx, |buffer, _| buffer.snapshot())?;
356 append_buffer_to_output(
357 &snapshot,
358 Some(Path::new(
359 path_including_worktree_name.display(path_style).as_ref(),
360 )),
361 &mut output,
362 )
363 .log_err();
364 let mut buffer_events = output.into_event_stream();
365 while let Some(event) = buffer_events.next().await {
366 events_tx.unbounded_send(event)?;
367 }
368 }
369 }
370 }
371
372 while directory_stack.pop().is_some() {
373 events_tx.unbounded_send(Ok(SlashCommandEvent::EndSection))?;
374 }
375 }
376
377 anyhow::Ok(())
378 })
379 .detach_and_log_err(cx);
380
381 events_rx.boxed()
382}
383
384pub fn codeblock_fence_for_path(
385 path: Option<&Path>,
386 row_range: Option<RangeInclusive<u32>>,
387) -> String {
388 let mut text = String::new();
389 write!(text, "```").unwrap();
390
391 if let Some(path) = path {
392 if let Some(extension) = path.extension().and_then(|ext| ext.to_str()) {
393 write!(text, "{} ", extension).unwrap();
394 }
395
396 write!(text, "{}", path.display()).unwrap();
397 } else {
398 write!(text, "untitled").unwrap();
399 }
400
401 if let Some(row_range) = row_range {
402 write!(text, ":{}-{}", row_range.start() + 1, row_range.end() + 1).unwrap();
403 }
404
405 text.push('\n');
406 text
407}
408
409#[derive(Serialize, Deserialize)]
410pub struct FileCommandMetadata {
411 pub path: String,
412}
413
414pub fn build_entry_output_section(
415 range: Range<usize>,
416 path: Option<&Path>,
417 is_directory: bool,
418 line_range: Option<Range<u32>>,
419) -> SlashCommandOutputSection<usize> {
420 let mut label = if let Some(path) = path {
421 path.to_string_lossy().to_string()
422 } else {
423 "untitled".to_string()
424 };
425 if let Some(line_range) = line_range {
426 write!(label, ":{}-{}", line_range.start, line_range.end).unwrap();
427 }
428
429 let icon = if is_directory {
430 IconName::Folder
431 } else {
432 IconName::File
433 };
434
435 SlashCommandOutputSection {
436 range,
437 icon,
438 label: label.into(),
439 metadata: if is_directory {
440 None
441 } else {
442 path.and_then(|path| {
443 serde_json::to_value(FileCommandMetadata {
444 path: path.to_string_lossy().to_string(),
445 })
446 .ok()
447 })
448 },
449 }
450}
451
452/// This contains a small fork of the util::paths::PathMatcher, that is stricter about the prefix
453/// check. Only subpaths pass the prefix check, rather than any prefix.
454mod custom_path_matcher {
455 use globset::{Glob, GlobSet, GlobSetBuilder};
456 use std::fmt::Debug as _;
457 use util::{paths::SanitizedPath, rel_path::RelPath};
458
459 #[derive(Clone, Debug, Default)]
460 pub struct PathMatcher {
461 sources: Vec<String>,
462 sources_with_trailing_slash: Vec<String>,
463 glob: GlobSet,
464 }
465
466 impl std::fmt::Display for PathMatcher {
467 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
468 self.sources.fmt(f)
469 }
470 }
471
472 impl PartialEq for PathMatcher {
473 fn eq(&self, other: &Self) -> bool {
474 self.sources.eq(&other.sources)
475 }
476 }
477
478 impl Eq for PathMatcher {}
479
480 impl PathMatcher {
481 pub fn new(globs: &[String]) -> Result<Self, globset::Error> {
482 let globs = globs
483 .iter()
484 .map(|glob| Glob::new(&SanitizedPath::new(glob).to_string()))
485 .collect::<Result<Vec<_>, _>>()?;
486 let sources = globs.iter().map(|glob| glob.glob().to_owned()).collect();
487 let sources_with_trailing_slash = globs
488 .iter()
489 .map(|glob| glob.glob().to_string() + "/")
490 .collect();
491 let mut glob_builder = GlobSetBuilder::new();
492 for single_glob in globs {
493 glob_builder.add(single_glob);
494 }
495 let glob = glob_builder.build()?;
496 Ok(PathMatcher {
497 glob,
498 sources,
499 sources_with_trailing_slash,
500 })
501 }
502
503 pub fn is_match(&self, other: &RelPath) -> bool {
504 self.sources
505 .iter()
506 .zip(self.sources_with_trailing_slash.iter())
507 .any(|(source, with_slash)| {
508 let as_bytes = other.as_str().as_bytes();
509 let with_slash = if source.ends_with('/') {
510 source.as_bytes()
511 } else {
512 with_slash.as_bytes()
513 };
514
515 as_bytes.starts_with(with_slash) || as_bytes.ends_with(source.as_bytes())
516 })
517 || self.glob.is_match(other)
518 || self.check_with_end_separator(other)
519 }
520
521 fn check_with_end_separator(&self, path: &RelPath) -> bool {
522 let path_str = path.as_str();
523 let separator = "/";
524 if path_str.ends_with(separator) {
525 false
526 } else {
527 self.glob.is_match(path_str.to_string() + separator)
528 }
529 }
530 }
531}
532
533pub fn append_buffer_to_output(
534 buffer: &BufferSnapshot,
535 path: Option<&Path>,
536 output: &mut SlashCommandOutput,
537) -> Result<()> {
538 let prev_len = output.text.len();
539
540 let mut content = buffer.text();
541 LineEnding::normalize(&mut content);
542 output.text.push_str(&codeblock_fence_for_path(path, None));
543 output.text.push_str(&content);
544 if !output.text.ends_with('\n') {
545 output.text.push('\n');
546 }
547 output.text.push_str("```");
548 output.text.push('\n');
549
550 let section_ix = output.sections.len();
551 output.sections.insert(
552 section_ix,
553 build_entry_output_section(prev_len..output.text.len(), path, false, None),
554 );
555
556 output.text.push('\n');
557
558 Ok(())
559}
560
561#[cfg(test)]
562mod test {
563 use assistant_slash_command::SlashCommandOutput;
564 use fs::FakeFs;
565 use gpui::TestAppContext;
566 use pretty_assertions::assert_eq;
567 use project::Project;
568 use serde_json::json;
569 use settings::SettingsStore;
570 use smol::stream::StreamExt;
571 use util::path;
572
573 use super::collect_files;
574
575 pub fn init_test(cx: &mut gpui::TestAppContext) {
576 zlog::init_test();
577
578 cx.update(|cx| {
579 let settings_store = SettingsStore::test(cx);
580 cx.set_global(settings_store);
581 // release_channel::init(SemanticVersion::default(), cx);
582 language::init(cx);
583 Project::init_settings(cx);
584 });
585 }
586
587 #[gpui::test]
588 async fn test_file_exact_matching(cx: &mut TestAppContext) {
589 init_test(cx);
590 let fs = FakeFs::new(cx.executor());
591
592 fs.insert_tree(
593 path!("/root"),
594 json!({
595 "dir": {
596 "subdir": {
597 "file_0": "0"
598 },
599 "file_1": "1",
600 "file_2": "2",
601 "file_3": "3",
602 },
603 "dir.rs": "4"
604 }),
605 )
606 .await;
607
608 let project = Project::test(fs, [path!("/root").as_ref()], cx).await;
609
610 let result_1 =
611 cx.update(|cx| collect_files(project.clone(), &["root/dir".to_string()], cx));
612 let result_1 = SlashCommandOutput::from_event_stream(result_1.boxed())
613 .await
614 .unwrap();
615
616 assert!(result_1.text.starts_with(path!("root/dir")));
617 // 4 files + 2 directories
618 assert_eq!(result_1.sections.len(), 6);
619
620 let result_2 =
621 cx.update(|cx| collect_files(project.clone(), &["root/dir/".to_string()], cx));
622 let result_2 = SlashCommandOutput::from_event_stream(result_2.boxed())
623 .await
624 .unwrap();
625
626 assert_eq!(result_1, result_2);
627
628 let result =
629 cx.update(|cx| collect_files(project.clone(), &["root/dir*".to_string()], cx).boxed());
630 let result = SlashCommandOutput::from_event_stream(result).await.unwrap();
631
632 assert!(result.text.starts_with(path!("root/dir")));
633 // 5 files + 2 directories
634 assert_eq!(result.sections.len(), 7);
635
636 // Ensure that the project lasts until after the last await
637 drop(project);
638 }
639
640 #[gpui::test]
641 async fn test_file_sub_directory_rendering(cx: &mut TestAppContext) {
642 init_test(cx);
643 let fs = FakeFs::new(cx.executor());
644
645 fs.insert_tree(
646 path!("/zed"),
647 json!({
648 "assets": {
649 "dir1": {
650 ".gitkeep": ""
651 },
652 "dir2": {
653 ".gitkeep": ""
654 },
655 "themes": {
656 "ayu": {
657 "LICENSE": "1",
658 },
659 "andromeda": {
660 "LICENSE": "2",
661 },
662 "summercamp": {
663 "LICENSE": "3",
664 },
665 },
666 },
667 }),
668 )
669 .await;
670
671 let project = Project::test(fs, [path!("/zed").as_ref()], cx).await;
672
673 let result =
674 cx.update(|cx| collect_files(project.clone(), &["zed/assets/themes".to_string()], cx));
675 let result = SlashCommandOutput::from_event_stream(result.boxed())
676 .await
677 .unwrap();
678
679 // Sanity check
680 assert!(result.text.starts_with(path!("zed/assets/themes\n")));
681 assert_eq!(result.sections.len(), 7);
682
683 // Ensure that full file paths are included in the real output
684 assert!(
685 result
686 .text
687 .contains(path!("zed/assets/themes/andromeda/LICENSE"))
688 );
689 assert!(result.text.contains(path!("zed/assets/themes/ayu/LICENSE")));
690 assert!(
691 result
692 .text
693 .contains(path!("zed/assets/themes/summercamp/LICENSE"))
694 );
695
696 assert_eq!(result.sections[5].label, "summercamp");
697
698 // Ensure that things are in descending order, with properly relativized paths
699 assert_eq!(
700 result.sections[0].label,
701 path!("zed/assets/themes/andromeda/LICENSE")
702 );
703 assert_eq!(result.sections[1].label, "andromeda");
704 assert_eq!(
705 result.sections[2].label,
706 path!("zed/assets/themes/ayu/LICENSE")
707 );
708 assert_eq!(result.sections[3].label, "ayu");
709 assert_eq!(
710 result.sections[4].label,
711 path!("zed/assets/themes/summercamp/LICENSE")
712 );
713
714 // Ensure that the project lasts until after the last await
715 drop(project);
716 }
717
718 #[gpui::test]
719 async fn test_file_deep_sub_directory_rendering(cx: &mut TestAppContext) {
720 init_test(cx);
721 let fs = FakeFs::new(cx.executor());
722
723 fs.insert_tree(
724 path!("/zed"),
725 json!({
726 "assets": {
727 "themes": {
728 "LICENSE": "1",
729 "summercamp": {
730 "LICENSE": "1",
731 "subdir": {
732 "LICENSE": "1",
733 "subsubdir": {
734 "LICENSE": "3",
735 }
736 }
737 },
738 },
739 },
740 }),
741 )
742 .await;
743
744 let project = Project::test(fs, [path!("/zed").as_ref()], cx).await;
745
746 let result =
747 cx.update(|cx| collect_files(project.clone(), &["zed/assets/themes".to_string()], cx));
748 let result = SlashCommandOutput::from_event_stream(result.boxed())
749 .await
750 .unwrap();
751
752 assert!(result.text.starts_with(path!("zed/assets/themes\n")));
753 assert_eq!(result.sections[0].label, path!("zed/assets/themes/LICENSE"));
754 assert_eq!(
755 result.sections[1].label,
756 path!("zed/assets/themes/summercamp/LICENSE")
757 );
758 assert_eq!(
759 result.sections[2].label,
760 path!("zed/assets/themes/summercamp/subdir/LICENSE")
761 );
762 assert_eq!(
763 result.sections[3].label,
764 path!("zed/assets/themes/summercamp/subdir/subsubdir/LICENSE")
765 );
766 assert_eq!(result.sections[4].label, "subsubdir");
767 assert_eq!(result.sections[5].label, "subdir");
768 assert_eq!(result.sections[6].label, "summercamp");
769 assert_eq!(result.sections[7].label, path!("zed/assets/themes"));
770
771 assert_eq!(
772 result.text,
773 path!(
774 "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"
775 )
776 );
777
778 // Ensure that the project lasts until after the last await
779 drop(project);
780 }
781}