file_command.rs

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