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