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::{AppContext, Model, Task, View, WeakView};
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
24use crate::slash_command::diagnostics_command::collect_buffer_diagnostics;
25
26pub(crate) struct FileSlashCommand;
27
28impl FileSlashCommand {
29 fn search_paths(
30 &self,
31 query: String,
32 cancellation_flag: Arc<AtomicBool>,
33 workspace: &View<Workspace>,
34 cx: &mut AppContext,
35 ) -> Task<Vec<PathMatch>> {
36 if query.is_empty() {
37 let workspace = workspace.read(cx);
38 let project = workspace.project().read(cx);
39 let entries = workspace.recent_navigation_history(Some(10), cx);
40
41 let entries = entries
42 .into_iter()
43 .map(|entries| (entries.0, false))
44 .chain(project.worktrees(cx).flat_map(|worktree| {
45 let worktree = worktree.read(cx);
46 let id = worktree.id();
47 worktree.child_entries(Path::new("")).map(move |entry| {
48 (
49 project::ProjectPath {
50 worktree_id: id,
51 path: entry.path.clone(),
52 },
53 entry.kind.is_dir(),
54 )
55 })
56 }))
57 .collect::<Vec<_>>();
58
59 let path_prefix: Arc<str> = Arc::default();
60 Task::ready(
61 entries
62 .into_iter()
63 .filter_map(|(entry, is_dir)| {
64 let worktree = project.worktree_for_id(entry.worktree_id, cx)?;
65 let mut full_path = PathBuf::from(worktree.read(cx).root_name());
66 full_path.push(&entry.path);
67 Some(PathMatch {
68 score: 0.,
69 positions: Vec::new(),
70 worktree_id: entry.worktree_id.to_usize(),
71 path: full_path.into(),
72 path_prefix: path_prefix.clone(),
73 distance_to_relative_ancestor: 0,
74 is_dir,
75 })
76 })
77 .collect(),
78 )
79 } else {
80 let worktrees = workspace.read(cx).visible_worktrees(cx).collect::<Vec<_>>();
81 let candidate_sets = worktrees
82 .into_iter()
83 .map(|worktree| {
84 let worktree = worktree.read(cx);
85
86 PathMatchCandidateSet {
87 snapshot: worktree.snapshot(),
88 include_ignored: worktree
89 .root_entry()
90 .map_or(false, |entry| entry.is_ignored),
91 include_root_name: true,
92 candidates: project::Candidates::Entries,
93 }
94 })
95 .collect::<Vec<_>>();
96
97 let executor = cx.background_executor().clone();
98 cx.foreground_executor().spawn(async move {
99 fuzzy::match_path_sets(
100 candidate_sets.as_slice(),
101 query.as_str(),
102 None,
103 false,
104 100,
105 &cancellation_flag,
106 executor,
107 )
108 .await
109 })
110 }
111 }
112}
113
114impl SlashCommand for FileSlashCommand {
115 fn name(&self) -> String {
116 "file".into()
117 }
118
119 fn description(&self) -> String {
120 "Insert file and/or directory".into()
121 }
122
123 fn menu_text(&self) -> String {
124 self.description()
125 }
126
127 fn requires_argument(&self) -> bool {
128 true
129 }
130
131 fn icon(&self) -> IconName {
132 IconName::File
133 }
134
135 fn complete_argument(
136 self: Arc<Self>,
137 arguments: &[String],
138 cancellation_flag: Arc<AtomicBool>,
139 workspace: Option<WeakView<Workspace>>,
140 cx: &mut WindowContext,
141 ) -> Task<Result<Vec<ArgumentCompletion>>> {
142 let Some(workspace) = workspace.and_then(|workspace| workspace.upgrade()) else {
143 return Task::ready(Err(anyhow!("workspace was dropped")));
144 };
145
146 let paths = self.search_paths(
147 arguments.last().cloned().unwrap_or_default(),
148 cancellation_flag,
149 &workspace,
150 cx,
151 );
152 let comment_id = cx.theme().syntax().highlight_id("comment").map(HighlightId);
153 cx.background_executor().spawn(async move {
154 Ok(paths
155 .await
156 .into_iter()
157 .filter_map(|path_match| {
158 let text = format!(
159 "{}{}",
160 path_match.path_prefix,
161 path_match.path.to_string_lossy()
162 );
163
164 let mut label = CodeLabel::default();
165 let file_name = path_match.path.file_name()?.to_string_lossy();
166 let label_text = if path_match.is_dir {
167 format!("{}/ ", file_name)
168 } else {
169 format!("{} ", file_name)
170 };
171
172 label.push_str(label_text.as_str(), None);
173 label.push_str(&text, comment_id);
174 label.filter_range = 0..file_name.len();
175
176 Some(ArgumentCompletion {
177 label,
178 new_text: text,
179 after_completion: AfterCompletion::Compose,
180 replace_previous_arguments: false,
181 })
182 })
183 .collect())
184 })
185 }
186
187 fn run(
188 self: Arc<Self>,
189 arguments: &[String],
190 _context_slash_command_output_sections: &[SlashCommandOutputSection<language::Anchor>],
191 _context_buffer: BufferSnapshot,
192 workspace: WeakView<Workspace>,
193 _delegate: Option<Arc<dyn LspAdapterDelegate>>,
194 cx: &mut WindowContext,
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: Model<Project>,
215 glob_inputs: &[String],
216 cx: &mut AppContext,
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
262 .unbounded_send(Ok(SlashCommandEvent::EndSection { metadata: None }))?;
263 events_tx.unbounded_send(Ok(SlashCommandEvent::Content(
264 SlashCommandContent::Text {
265 text: "\n".into(),
266 run_commands_in_text: false,
267 },
268 )))?;
269 }
270
271 let filename = entry
272 .path
273 .file_name()
274 .unwrap_or_default()
275 .to_str()
276 .unwrap_or_default()
277 .to_string();
278
279 if entry.is_dir() {
280 // Auto-fold directories that contain no files
281 let mut child_entries = snapshot.child_entries(&entry.path);
282 if let Some(child) = child_entries.next() {
283 if child_entries.next().is_none() && child.kind.is_dir() {
284 if is_top_level_directory {
285 is_top_level_directory = false;
286 folded_directory_names_stack.push(
287 path_including_worktree_name.to_string_lossy().to_string(),
288 );
289 } else {
290 folded_directory_names_stack.push(filename.to_string());
291 }
292 continue;
293 }
294 } else {
295 // Skip empty directories
296 folded_directory_names_stack.clear();
297 continue;
298 }
299 let prefix_paths = folded_directory_names_stack.drain(..).as_slice().join("/");
300 if prefix_paths.is_empty() {
301 let label = if is_top_level_directory {
302 is_top_level_directory = false;
303 path_including_worktree_name.to_string_lossy().to_string()
304 } else {
305 filename
306 };
307 events_tx.unbounded_send(Ok(SlashCommandEvent::StartSection {
308 icon: IconName::Folder,
309 label: label.clone().into(),
310 metadata: None,
311 }))?;
312 events_tx.unbounded_send(Ok(SlashCommandEvent::Content(
313 SlashCommandContent::Text {
314 text: label,
315 run_commands_in_text: false,
316 },
317 )))?;
318 directory_stack.push(entry.path.clone());
319 } else {
320 let entry_name = format!("{}/{}", prefix_paths, &filename);
321 events_tx.unbounded_send(Ok(SlashCommandEvent::StartSection {
322 icon: IconName::Folder,
323 label: entry_name.clone().into(),
324 metadata: None,
325 }))?;
326 events_tx.unbounded_send(Ok(SlashCommandEvent::Content(
327 SlashCommandContent::Text {
328 text: entry_name,
329 run_commands_in_text: false,
330 },
331 )))?;
332 directory_stack.push(entry.path.clone());
333 }
334 events_tx.unbounded_send(Ok(SlashCommandEvent::Content(
335 SlashCommandContent::Text {
336 text: "\n".into(),
337 run_commands_in_text: false,
338 },
339 )))?;
340 } else if entry.is_file() {
341 let Some(open_buffer_task) = project_handle
342 .update(&mut cx, |project, cx| {
343 project.open_buffer((worktree_id, &entry.path), cx)
344 })
345 .ok()
346 else {
347 continue;
348 };
349 if let Some(buffer) = open_buffer_task.await.log_err() {
350 let mut output = SlashCommandOutput::default();
351 let snapshot = buffer.read_with(&cx, |buffer, _| buffer.snapshot())?;
352 append_buffer_to_output(
353 &snapshot,
354 Some(&path_including_worktree_name),
355 &mut output,
356 )
357 .log_err();
358 let mut buffer_events = output.to_event_stream();
359 while let Some(event) = buffer_events.next().await {
360 events_tx.unbounded_send(event)?;
361 }
362 }
363 }
364 }
365
366 while let Some(_) = directory_stack.pop() {
367 events_tx.unbounded_send(Ok(SlashCommandEvent::EndSection { metadata: None }))?;
368 }
369 }
370
371 anyhow::Ok(())
372 })
373 .detach_and_log_err(cx);
374
375 events_rx.boxed()
376}
377
378pub fn codeblock_fence_for_path(
379 path: Option<&Path>,
380 row_range: Option<RangeInclusive<u32>>,
381) -> String {
382 let mut text = String::new();
383 write!(text, "```").unwrap();
384
385 if let Some(path) = path {
386 if let Some(extension) = path.extension().and_then(|ext| ext.to_str()) {
387 write!(text, "{} ", extension).unwrap();
388 }
389
390 write!(text, "{}", path.display()).unwrap();
391 } else {
392 write!(text, "untitled").unwrap();
393 }
394
395 if let Some(row_range) = row_range {
396 write!(text, ":{}-{}", row_range.start() + 1, row_range.end() + 1).unwrap();
397 }
398
399 text.push('\n');
400 text
401}
402
403#[derive(Serialize, Deserialize)]
404pub struct FileCommandMetadata {
405 pub path: String,
406}
407
408pub fn build_entry_output_section(
409 range: Range<usize>,
410 path: Option<&Path>,
411 is_directory: bool,
412 line_range: Option<Range<u32>>,
413) -> SlashCommandOutputSection<usize> {
414 let mut label = if let Some(path) = path {
415 path.to_string_lossy().to_string()
416 } else {
417 "untitled".to_string()
418 };
419 if let Some(line_range) = line_range {
420 write!(label, ":{}-{}", line_range.start, line_range.end).unwrap();
421 }
422
423 let icon = if is_directory {
424 IconName::Folder
425 } else {
426 IconName::File
427 };
428
429 SlashCommandOutputSection {
430 range,
431 icon,
432 label: label.into(),
433 metadata: if is_directory {
434 None
435 } else {
436 path.and_then(|path| {
437 serde_json::to_value(FileCommandMetadata {
438 path: path.to_string_lossy().to_string(),
439 })
440 .ok()
441 })
442 },
443 }
444}
445
446/// This contains a small fork of the util::paths::PathMatcher, that is stricter about the prefix
447/// check. Only subpaths pass the prefix check, rather than any prefix.
448mod custom_path_matcher {
449 use std::{fmt::Debug as _, path::Path};
450
451 use globset::{Glob, GlobSet, GlobSetBuilder};
452
453 #[derive(Clone, Debug, Default)]
454 pub struct PathMatcher {
455 sources: Vec<String>,
456 sources_with_trailing_slash: Vec<String>,
457 glob: GlobSet,
458 }
459
460 impl std::fmt::Display for PathMatcher {
461 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
462 self.sources.fmt(f)
463 }
464 }
465
466 impl PartialEq for PathMatcher {
467 fn eq(&self, other: &Self) -> bool {
468 self.sources.eq(&other.sources)
469 }
470 }
471
472 impl Eq for PathMatcher {}
473
474 impl PathMatcher {
475 pub fn new(globs: &[String]) -> Result<Self, globset::Error> {
476 let globs = globs
477 .into_iter()
478 .map(|glob| Glob::new(&glob))
479 .collect::<Result<Vec<_>, _>>()?;
480 let sources = globs.iter().map(|glob| glob.glob().to_owned()).collect();
481 let sources_with_trailing_slash = globs
482 .iter()
483 .map(|glob| glob.glob().to_string() + std::path::MAIN_SEPARATOR_STR)
484 .collect();
485 let mut glob_builder = GlobSetBuilder::new();
486 for single_glob in globs {
487 glob_builder.add(single_glob);
488 }
489 let glob = glob_builder.build()?;
490 Ok(PathMatcher {
491 glob,
492 sources,
493 sources_with_trailing_slash,
494 })
495 }
496
497 pub fn is_match<P: AsRef<Path>>(&self, other: P) -> bool {
498 let other_path = other.as_ref();
499 self.sources
500 .iter()
501 .zip(self.sources_with_trailing_slash.iter())
502 .any(|(source, with_slash)| {
503 let as_bytes = other_path.as_os_str().as_encoded_bytes();
504 let with_slash = if source.ends_with("/") {
505 source.as_bytes()
506 } else {
507 with_slash.as_bytes()
508 };
509
510 as_bytes.starts_with(with_slash) || as_bytes.ends_with(source.as_bytes())
511 })
512 || self.glob.is_match(other_path)
513 || self.check_with_end_separator(other_path)
514 }
515
516 fn check_with_end_separator(&self, path: &Path) -> bool {
517 let path_str = path.to_string_lossy();
518 let separator = std::path::MAIN_SEPARATOR_STR;
519 if path_str.ends_with(separator) {
520 return false;
521 } else {
522 self.glob.is_match(path_str.to_string() + separator)
523 }
524 }
525 }
526}
527
528pub fn append_buffer_to_output(
529 buffer: &BufferSnapshot,
530 path: Option<&Path>,
531 output: &mut SlashCommandOutput,
532) -> Result<()> {
533 let prev_len = output.text.len();
534
535 let mut content = buffer.text();
536 LineEnding::normalize(&mut content);
537 output.text.push_str(&codeblock_fence_for_path(path, None));
538 output.text.push_str(&content);
539 if !output.text.ends_with('\n') {
540 output.text.push('\n');
541 }
542 output.text.push_str("```");
543 output.text.push('\n');
544
545 let section_ix = output.sections.len();
546 collect_buffer_diagnostics(output, buffer, false);
547
548 output.sections.insert(
549 section_ix,
550 build_entry_output_section(prev_len..output.text.len(), path, false, None),
551 );
552
553 output.text.push('\n');
554
555 Ok(())
556}
557
558#[cfg(test)]
559mod test {
560 use assistant_slash_command::SlashCommandOutput;
561 use fs::FakeFs;
562 use gpui::TestAppContext;
563 use pretty_assertions::assert_eq;
564 use project::Project;
565 use serde_json::json;
566 use settings::SettingsStore;
567 use smol::stream::StreamExt;
568
569 use crate::slash_command::file_command::collect_files;
570
571 pub fn init_test(cx: &mut gpui::TestAppContext) {
572 if std::env::var("RUST_LOG").is_ok() {
573 env_logger::try_init().ok();
574 }
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 "/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, ["/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("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("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 "/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, ["/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("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!(result.text.contains("zed/assets/themes/andromeda/LICENSE"));
683 assert!(result.text.contains("zed/assets/themes/ayu/LICENSE"));
684 assert!(result.text.contains("zed/assets/themes/summercamp/LICENSE"));
685
686 assert_eq!(result.sections[5].label, "summercamp");
687
688 // Ensure that things are in descending order, with properly relativized paths
689 assert_eq!(
690 result.sections[0].label,
691 "zed/assets/themes/andromeda/LICENSE"
692 );
693 assert_eq!(result.sections[1].label, "andromeda");
694 assert_eq!(result.sections[2].label, "zed/assets/themes/ayu/LICENSE");
695 assert_eq!(result.sections[3].label, "ayu");
696 assert_eq!(
697 result.sections[4].label,
698 "zed/assets/themes/summercamp/LICENSE"
699 );
700
701 // Ensure that the project lasts until after the last await
702 drop(project);
703 }
704
705 #[gpui::test]
706 async fn test_file_deep_sub_directory_rendering(cx: &mut TestAppContext) {
707 init_test(cx);
708 let fs = FakeFs::new(cx.executor());
709
710 fs.insert_tree(
711 "/zed",
712 json!({
713 "assets": {
714 "themes": {
715 "LICENSE": "1",
716 "summercamp": {
717 "LICENSE": "1",
718 "subdir": {
719 "LICENSE": "1",
720 "subsubdir": {
721 "LICENSE": "3",
722 }
723 }
724 },
725 },
726 },
727 }),
728 )
729 .await;
730
731 let project = Project::test(fs, ["/zed".as_ref()], cx).await;
732
733 let result =
734 cx.update(|cx| collect_files(project.clone(), &["zed/assets/themes".to_string()], cx));
735 let result = SlashCommandOutput::from_event_stream(result.boxed())
736 .await
737 .unwrap();
738
739 assert!(result.text.starts_with("zed/assets/themes\n"));
740 assert_eq!(result.sections[0].label, "zed/assets/themes/LICENSE");
741 assert_eq!(
742 result.sections[1].label,
743 "zed/assets/themes/summercamp/LICENSE"
744 );
745 assert_eq!(
746 result.sections[2].label,
747 "zed/assets/themes/summercamp/subdir/LICENSE"
748 );
749 assert_eq!(
750 result.sections[3].label,
751 "zed/assets/themes/summercamp/subdir/subsubdir/LICENSE"
752 );
753 assert_eq!(result.sections[4].label, "subsubdir");
754 assert_eq!(result.sections[5].label, "subdir");
755 assert_eq!(result.sections[6].label, "summercamp");
756 assert_eq!(result.sections[7].label, "zed/assets/themes");
757
758 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");
759
760 // Ensure that the project lasts until after the last await
761 drop(project);
762 }
763}