file_command.rs

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