file_command.rs

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