file_command.rs

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