file_command.rs

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