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