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