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