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