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