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