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