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