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