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