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 {
234 anyhow::bail!("invalid path");
235 })
236 .boxed();
237 };
238
239 let project_handle = project.downgrade();
240 let snapshots = project
241 .read(cx)
242 .worktrees(cx)
243 .map(|worktree| worktree.read(cx).snapshot())
244 .collect::<Vec<_>>();
245
246 let (events_tx, events_rx) = mpsc::unbounded();
247 cx.spawn(async move |cx| {
248 for snapshot in snapshots {
249 let worktree_id = snapshot.id();
250 let mut directory_stack: Vec<Arc<Path>> = Vec::new();
251 let mut folded_directory_names_stack = Vec::new();
252 let mut is_top_level_directory = true;
253
254 for entry in snapshot.entries(false, 0) {
255 let mut path_including_worktree_name = PathBuf::new();
256 path_including_worktree_name.push(snapshot.root_name());
257 path_including_worktree_name.push(&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
281 .path
282 .file_name()
283 .unwrap_or_default()
284 .to_str()
285 .unwrap_or_default()
286 .to_string();
287
288 if entry.is_dir() {
289 // Auto-fold directories that contain no files
290 let mut child_entries = snapshot.child_entries(&entry.path);
291 if let Some(child) = child_entries.next() {
292 if child_entries.next().is_none() && child.kind.is_dir() {
293 if is_top_level_directory {
294 is_top_level_directory = false;
295 folded_directory_names_stack.push(
296 path_including_worktree_name.to_string_lossy().to_string(),
297 );
298 } else {
299 folded_directory_names_stack.push(filename.to_string());
300 }
301 continue;
302 }
303 } else {
304 // Skip empty directories
305 folded_directory_names_stack.clear();
306 continue;
307 }
308 let prefix_paths = folded_directory_names_stack.drain(..).as_slice().join("/");
309 if prefix_paths.is_empty() {
310 let label = if is_top_level_directory {
311 is_top_level_directory = false;
312 path_including_worktree_name.to_string_lossy().to_string()
313 } else {
314 filename
315 };
316 events_tx.unbounded_send(Ok(SlashCommandEvent::StartSection {
317 icon: IconName::Folder,
318 label: label.clone().into(),
319 metadata: None,
320 }))?;
321 events_tx.unbounded_send(Ok(SlashCommandEvent::Content(
322 SlashCommandContent::Text {
323 text: label,
324 run_commands_in_text: false,
325 },
326 )))?;
327 directory_stack.push(entry.path.clone());
328 } else {
329 // todo(windows)
330 // Potential bug: this assumes that the path separator is always `\` on Windows
331 let entry_name = format!(
332 "{}{}{}",
333 prefix_paths,
334 std::path::MAIN_SEPARATOR_STR,
335 &filename
336 );
337 events_tx.unbounded_send(Ok(SlashCommandEvent::StartSection {
338 icon: IconName::Folder,
339 label: entry_name.clone().into(),
340 metadata: None,
341 }))?;
342 events_tx.unbounded_send(Ok(SlashCommandEvent::Content(
343 SlashCommandContent::Text {
344 text: entry_name,
345 run_commands_in_text: false,
346 },
347 )))?;
348 directory_stack.push(entry.path.clone());
349 }
350 events_tx.unbounded_send(Ok(SlashCommandEvent::Content(
351 SlashCommandContent::Text {
352 text: "\n".into(),
353 run_commands_in_text: false,
354 },
355 )))?;
356 } else if entry.is_file() {
357 let Some(open_buffer_task) = project_handle
358 .update(cx, |project, cx| {
359 project.open_buffer((worktree_id, &entry.path), cx)
360 })
361 .ok()
362 else {
363 continue;
364 };
365 if let Some(buffer) = open_buffer_task.await.log_err() {
366 let mut output = SlashCommandOutput::default();
367 let snapshot = buffer.read_with(cx, |buffer, _| buffer.snapshot())?;
368 append_buffer_to_output(
369 &snapshot,
370 Some(&path_including_worktree_name),
371 &mut output,
372 )
373 .log_err();
374 let mut buffer_events = output.to_event_stream();
375 while let Some(event) = buffer_events.next().await {
376 events_tx.unbounded_send(event)?;
377 }
378 }
379 }
380 }
381
382 while let Some(_) = directory_stack.pop() {
383 events_tx.unbounded_send(Ok(SlashCommandEvent::EndSection))?;
384 }
385 }
386
387 anyhow::Ok(())
388 })
389 .detach_and_log_err(cx);
390
391 events_rx.boxed()
392}
393
394pub fn codeblock_fence_for_path(
395 path: Option<&Path>,
396 row_range: Option<RangeInclusive<u32>>,
397) -> String {
398 let mut text = String::new();
399 write!(text, "```").unwrap();
400
401 if let Some(path) = path {
402 if let Some(extension) = path.extension().and_then(|ext| ext.to_str()) {
403 write!(text, "{} ", extension).unwrap();
404 }
405
406 write!(text, "{}", path.display()).unwrap();
407 } else {
408 write!(text, "untitled").unwrap();
409 }
410
411 if let Some(row_range) = row_range {
412 write!(text, ":{}-{}", row_range.start() + 1, row_range.end() + 1).unwrap();
413 }
414
415 text.push('\n');
416 text
417}
418
419#[derive(Serialize, Deserialize)]
420pub struct FileCommandMetadata {
421 pub path: String,
422}
423
424pub fn build_entry_output_section(
425 range: Range<usize>,
426 path: Option<&Path>,
427 is_directory: bool,
428 line_range: Option<Range<u32>>,
429) -> SlashCommandOutputSection<usize> {
430 let mut label = if let Some(path) = path {
431 path.to_string_lossy().to_string()
432 } else {
433 "untitled".to_string()
434 };
435 if let Some(line_range) = line_range {
436 write!(label, ":{}-{}", line_range.start, line_range.end).unwrap();
437 }
438
439 let icon = if is_directory {
440 IconName::Folder
441 } else {
442 IconName::File
443 };
444
445 SlashCommandOutputSection {
446 range,
447 icon,
448 label: label.into(),
449 metadata: if is_directory {
450 None
451 } else {
452 path.and_then(|path| {
453 serde_json::to_value(FileCommandMetadata {
454 path: path.to_string_lossy().to_string(),
455 })
456 .ok()
457 })
458 },
459 }
460}
461
462/// This contains a small fork of the util::paths::PathMatcher, that is stricter about the prefix
463/// check. Only subpaths pass the prefix check, rather than any prefix.
464mod custom_path_matcher {
465 use std::{fmt::Debug as _, path::Path};
466
467 use globset::{Glob, GlobSet, GlobSetBuilder};
468 use util::paths::SanitizedPath;
469
470 #[derive(Clone, Debug, Default)]
471 pub struct PathMatcher {
472 sources: Vec<String>,
473 sources_with_trailing_slash: Vec<String>,
474 glob: GlobSet,
475 }
476
477 impl std::fmt::Display for PathMatcher {
478 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
479 self.sources.fmt(f)
480 }
481 }
482
483 impl PartialEq for PathMatcher {
484 fn eq(&self, other: &Self) -> bool {
485 self.sources.eq(&other.sources)
486 }
487 }
488
489 impl Eq for PathMatcher {}
490
491 impl PathMatcher {
492 pub fn new(globs: &[String]) -> Result<Self, globset::Error> {
493 let globs = globs
494 .into_iter()
495 .map(|glob| Glob::new(&SanitizedPath::from(glob).to_glob_string()))
496 .collect::<Result<Vec<_>, _>>()?;
497 let sources = globs.iter().map(|glob| glob.glob().to_owned()).collect();
498 let sources_with_trailing_slash = globs
499 .iter()
500 .map(|glob| glob.glob().to_string() + std::path::MAIN_SEPARATOR_STR)
501 .collect();
502 let mut glob_builder = GlobSetBuilder::new();
503 for single_glob in globs {
504 glob_builder.add(single_glob);
505 }
506 let glob = glob_builder.build()?;
507 Ok(PathMatcher {
508 glob,
509 sources,
510 sources_with_trailing_slash,
511 })
512 }
513
514 pub fn is_match<P: AsRef<Path>>(&self, other: P) -> bool {
515 let other_path = other.as_ref();
516 self.sources
517 .iter()
518 .zip(self.sources_with_trailing_slash.iter())
519 .any(|(source, with_slash)| {
520 let as_bytes = other_path.as_os_str().as_encoded_bytes();
521 // todo(windows)
522 // Potential bug: this assumes that the path separator is always `\` on Windows
523 let with_slash = if source.ends_with(std::path::MAIN_SEPARATOR_STR) {
524 source.as_bytes()
525 } else {
526 with_slash.as_bytes()
527 };
528
529 as_bytes.starts_with(with_slash) || as_bytes.ends_with(source.as_bytes())
530 })
531 || self.glob.is_match(other_path)
532 || self.check_with_end_separator(other_path)
533 }
534
535 fn check_with_end_separator(&self, path: &Path) -> bool {
536 let path_str = path.to_string_lossy();
537 let separator = std::path::MAIN_SEPARATOR_STR;
538 if path_str.ends_with(separator) {
539 return false;
540 } else {
541 self.glob.is_match(path_str.to_string() + separator)
542 }
543 }
544 }
545}
546
547pub fn append_buffer_to_output(
548 buffer: &BufferSnapshot,
549 path: Option<&Path>,
550 output: &mut SlashCommandOutput,
551) -> Result<()> {
552 let prev_len = output.text.len();
553
554 let mut content = buffer.text();
555 LineEnding::normalize(&mut content);
556 output.text.push_str(&codeblock_fence_for_path(path, None));
557 output.text.push_str(&content);
558 if !output.text.ends_with('\n') {
559 output.text.push('\n');
560 }
561 output.text.push_str("```");
562 output.text.push('\n');
563
564 let section_ix = output.sections.len();
565 output.sections.insert(
566 section_ix,
567 build_entry_output_section(prev_len..output.text.len(), path, false, None),
568 );
569
570 output.text.push('\n');
571
572 Ok(())
573}
574
575#[cfg(test)]
576mod test {
577 use assistant_slash_command::SlashCommandOutput;
578 use fs::FakeFs;
579 use gpui::TestAppContext;
580 use pretty_assertions::assert_eq;
581 use project::Project;
582 use serde_json::json;
583 use settings::SettingsStore;
584 use smol::stream::StreamExt;
585 use util::{path, separator};
586
587 use super::collect_files;
588
589 pub fn init_test(cx: &mut gpui::TestAppContext) {
590 if std::env::var("RUST_LOG").is_ok() {
591 env_logger::try_init().ok();
592 }
593
594 cx.update(|cx| {
595 let settings_store = SettingsStore::test(cx);
596 cx.set_global(settings_store);
597 // release_channel::init(SemanticVersion::default(), cx);
598 language::init(cx);
599 Project::init_settings(cx);
600 });
601 }
602
603 #[gpui::test]
604 async fn test_file_exact_matching(cx: &mut TestAppContext) {
605 init_test(cx);
606 let fs = FakeFs::new(cx.executor());
607
608 fs.insert_tree(
609 path!("/root"),
610 json!({
611 "dir": {
612 "subdir": {
613 "file_0": "0"
614 },
615 "file_1": "1",
616 "file_2": "2",
617 "file_3": "3",
618 },
619 "dir.rs": "4"
620 }),
621 )
622 .await;
623
624 let project = Project::test(fs, [path!("/root").as_ref()], cx).await;
625
626 let result_1 =
627 cx.update(|cx| collect_files(project.clone(), &["root/dir".to_string()], cx));
628 let result_1 = SlashCommandOutput::from_event_stream(result_1.boxed())
629 .await
630 .unwrap();
631
632 assert!(result_1.text.starts_with(separator!("root/dir")));
633 // 4 files + 2 directories
634 assert_eq!(result_1.sections.len(), 6);
635
636 let result_2 =
637 cx.update(|cx| collect_files(project.clone(), &["root/dir/".to_string()], cx));
638 let result_2 = SlashCommandOutput::from_event_stream(result_2.boxed())
639 .await
640 .unwrap();
641
642 assert_eq!(result_1, result_2);
643
644 let result =
645 cx.update(|cx| collect_files(project.clone(), &["root/dir*".to_string()], cx).boxed());
646 let result = SlashCommandOutput::from_event_stream(result).await.unwrap();
647
648 assert!(result.text.starts_with(separator!("root/dir")));
649 // 5 files + 2 directories
650 assert_eq!(result.sections.len(), 7);
651
652 // Ensure that the project lasts until after the last await
653 drop(project);
654 }
655
656 #[gpui::test]
657 async fn test_file_sub_directory_rendering(cx: &mut TestAppContext) {
658 init_test(cx);
659 let fs = FakeFs::new(cx.executor());
660
661 fs.insert_tree(
662 path!("/zed"),
663 json!({
664 "assets": {
665 "dir1": {
666 ".gitkeep": ""
667 },
668 "dir2": {
669 ".gitkeep": ""
670 },
671 "themes": {
672 "ayu": {
673 "LICENSE": "1",
674 },
675 "andromeda": {
676 "LICENSE": "2",
677 },
678 "summercamp": {
679 "LICENSE": "3",
680 },
681 },
682 },
683 }),
684 )
685 .await;
686
687 let project = Project::test(fs, [path!("/zed").as_ref()], cx).await;
688
689 let result =
690 cx.update(|cx| collect_files(project.clone(), &["zed/assets/themes".to_string()], cx));
691 let result = SlashCommandOutput::from_event_stream(result.boxed())
692 .await
693 .unwrap();
694
695 // Sanity check
696 assert!(result.text.starts_with(separator!("zed/assets/themes\n")));
697 assert_eq!(result.sections.len(), 7);
698
699 // Ensure that full file paths are included in the real output
700 assert!(
701 result
702 .text
703 .contains(separator!("zed/assets/themes/andromeda/LICENSE"))
704 );
705 assert!(
706 result
707 .text
708 .contains(separator!("zed/assets/themes/ayu/LICENSE"))
709 );
710 assert!(
711 result
712 .text
713 .contains(separator!("zed/assets/themes/summercamp/LICENSE"))
714 );
715
716 assert_eq!(result.sections[5].label, "summercamp");
717
718 // Ensure that things are in descending order, with properly relativized paths
719 assert_eq!(
720 result.sections[0].label,
721 separator!("zed/assets/themes/andromeda/LICENSE")
722 );
723 assert_eq!(result.sections[1].label, "andromeda");
724 assert_eq!(
725 result.sections[2].label,
726 separator!("zed/assets/themes/ayu/LICENSE")
727 );
728 assert_eq!(result.sections[3].label, "ayu");
729 assert_eq!(
730 result.sections[4].label,
731 separator!("zed/assets/themes/summercamp/LICENSE")
732 );
733
734 // Ensure that the project lasts until after the last await
735 drop(project);
736 }
737
738 #[gpui::test]
739 async fn test_file_deep_sub_directory_rendering(cx: &mut TestAppContext) {
740 init_test(cx);
741 let fs = FakeFs::new(cx.executor());
742
743 fs.insert_tree(
744 path!("/zed"),
745 json!({
746 "assets": {
747 "themes": {
748 "LICENSE": "1",
749 "summercamp": {
750 "LICENSE": "1",
751 "subdir": {
752 "LICENSE": "1",
753 "subsubdir": {
754 "LICENSE": "3",
755 }
756 }
757 },
758 },
759 },
760 }),
761 )
762 .await;
763
764 let project = Project::test(fs, [path!("/zed").as_ref()], cx).await;
765
766 let result =
767 cx.update(|cx| collect_files(project.clone(), &["zed/assets/themes".to_string()], cx));
768 let result = SlashCommandOutput::from_event_stream(result.boxed())
769 .await
770 .unwrap();
771
772 assert!(result.text.starts_with(separator!("zed/assets/themes\n")));
773 assert_eq!(
774 result.sections[0].label,
775 separator!("zed/assets/themes/LICENSE")
776 );
777 assert_eq!(
778 result.sections[1].label,
779 separator!("zed/assets/themes/summercamp/LICENSE")
780 );
781 assert_eq!(
782 result.sections[2].label,
783 separator!("zed/assets/themes/summercamp/subdir/LICENSE")
784 );
785 assert_eq!(
786 result.sections[3].label,
787 separator!("zed/assets/themes/summercamp/subdir/subsubdir/LICENSE")
788 );
789 assert_eq!(result.sections[4].label, "subsubdir");
790 assert_eq!(result.sections[5].label, "subdir");
791 assert_eq!(result.sections[6].label, "summercamp");
792 assert_eq!(result.sections[7].label, separator!("zed/assets/themes"));
793
794 assert_eq!(
795 result.text,
796 separator!(
797 "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"
798 )
799 );
800
801 // Ensure that the project lasts until after the last await
802 drop(project);
803 }
804}