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