file_command.rs

  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}