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