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