Skip to main content

strop_engine/editor/
io.rs

1//! File I/O is owned work. Only matching completions may publish into a view.
2mod codec;
3pub(super) mod native;
4mod remote;
5#[cfg(test)]
6mod remote_tests;
7mod save;
8use super::{Document, Editor};
9use crate::files::FileTarget;
10use std::collections::HashMap;
11use std::path::PathBuf;
12use std::sync::mpsc::{self, Receiver, Sender};
13use strop_core::id::{BufferRevision, ByteColumn, DocumentId, LineIndex};
14use strop_core::worker::{self, Completion, FailureKind, Outcome, Ticket, WorkerId};
15use strop_core::{Buffer, SaveReceipt};
16
17#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
18pub enum OpenIntent {
19    Switch {
20        readonly: bool,
21    },
22    Split {
23        vertical: bool,
24    },
25    AtLine {
26        line: LineIndex,
27    },
28    Refresh,
29    Browse,
30    DirectoryParent {
31        child: strop_workspace::RemoteFile,
32    },
33    RemoteDestination,
34    RemoteView {
35        view: super::remote::RemoteView,
36        line: Option<LineIndex>,
37    },
38    Grep {
39        line: LineIndex,
40        column: ByteColumn,
41    },
42    SearchHit(super::picker::ReplacementHit),
43    LspLocation {
44        context: strop_lsp::ReplyContext,
45        position: strop_lsp::ServerPosition,
46    },
47    /// Open without focus (0044 v2): collection builds load sources in
48    /// the background; the picker keeps focus and focus never moves.
49    CollectionSource {
50        owner: WorkerId,
51    },
52}
53impl OpenIntent {
54    fn requires_file(&self) -> bool {
55        match self {
56            Self::AtLine { .. }
57            | Self::Grep { .. }
58            | Self::SearchHit(_)
59            | Self::LspLocation { .. } => true,
60            Self::RemoteView { view, line } => {
61                line.is_some() || *view != super::remote::RemoteView::default()
62            }
63            _ => false,
64        }
65    }
66}
67
68#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
69pub struct OpenKey {
70    pub path: FileTarget,
71    pub origin: DocumentId,
72    pub revision: BufferRevision,
73    pub focus: u64,
74    pub intent: OpenIntent,
75    pub selection: strop_remote::ReadSelection,
76}
77
78pub struct Opened {
79    pub document: Document,
80    pub canonical: FileTarget,
81}
82
83#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
84pub struct SaveKey {
85    pub document: DocumentId,
86    pub revision: BufferRevision,
87    pub focus: u64,
88    pub close: bool,
89    #[serde(with = "strop_core::path_serde::option")]
90    pub target: Option<PathBuf>,
91    pub force: bool,
92}
93
94#[derive(serde::Serialize, serde::Deserialize)]
95pub enum IoEvent {
96    Open(Box<Completion<OpenKey, Opened>>),
97    Save(Box<Completion<SaveKey, SaveReceipt>>),
98    Native(Box<Completion<native::NativeKey, native::NativeResult>>),
99    Remote(super::remote::RemoteEvent),
100    Review(
101        Box<
102            Completion<
103                super::changes::review::prepare::PreparationKey,
104                super::changes::review::prepare::PreparedReview,
105            >,
106        >,
107    ),
108    Session {
109        request: WorkerId,
110        outcome: Outcome<()>,
111    },
112}
113
114pub struct IoState {
115    pub tx: Sender<IoEvent>,
116    pub rx: Option<Receiver<IoEvent>>,
117    pub open: HashMap<WorkerId, OpenKey>,
118    navigation: Option<WorkerId>,
119    saves: HashMap<DocumentId, Ticket<SaveKey>>,
120    session: Option<WorkerId>,
121    queued_session: Option<crate::session::SaveRequest>,
122    native: HashMap<WorkerId, native::NativeKey>,
123    pub session_error: Option<String>,
124    pub(crate) format_warnings: HashMap<DocumentId, String>,
125}
126
127impl Default for IoState {
128    fn default() -> Self {
129        let (tx, rx) = mpsc::channel();
130        Self {
131            tx,
132            rx: Some(rx),
133            open: HashMap::new(),
134            navigation: None,
135            saves: HashMap::new(),
136            session: None,
137            queued_session: None,
138            native: HashMap::new(),
139            session_error: None,
140            format_warnings: HashMap::new(),
141        }
142    }
143}
144
145impl Editor {
146    pub fn request_open(&mut self, path: PathBuf, intent: OpenIntent) {
147        self.request_target(FileTarget::Local(path), intent);
148    }
149
150    pub fn request_target(&mut self, target: FileTarget, intent: OpenIntent) {
151        if matches!(intent, OpenIntent::Refresh) && self.remote_write_blocks_refresh(self.current())
152        {
153            self.message =
154                "remote save pending or unconfirmed; settle or :remote verify before refresh"
155                    .into();
156            return;
157        }
158        let selection = match &intent {
159            OpenIntent::RemoteView { view, .. } => view.selection(),
160            OpenIntent::Refresh => self
161                .cur()
162                .remote_metadata()
163                .map_or(strop_remote::ReadSelection::Full, |source| source.selection),
164            _ => strop_remote::ReadSelection::Full,
165        };
166        let requires_file = intent.requires_file();
167        let browse = matches!(
168            intent,
169            OpenIntent::Browse | OpenIntent::DirectoryParent { .. }
170        );
171        if matches!(target, FileTarget::Local(_))
172            && (browse
173                || matches!(
174                    intent,
175                    OpenIntent::RemoteView { .. } | OpenIntent::RemoteDestination
176                ))
177        {
178            self.message = "range/tail/follow views require a remote target".into();
179            return;
180        }
181        let path = match target {
182            FileTarget::Local(path) => FileTarget::Local(self.cwd.join(path)),
183            remote => remote,
184        };
185        self.cancel_open(worker::CancelReason::Superseded);
186        let existing = self.docs.iter().find_map(|(id, document)| {
187            (document.matches_target(&path)
188                && document
189                    .remote_metadata()
190                    .is_none_or(|source| source.selection == selection))
191            .then_some(id)
192        });
193        if let Some(id) = existing.filter(|&id| {
194            !matches!(intent, OpenIntent::Refresh)
195                && (!matches!(intent, OpenIntent::Browse | OpenIntent::RemoteDestination)
196                    || self.doc(id).directory_metadata_ref().is_none())
197        }) {
198            if (requires_file && self.doc(id).directory_metadata_ref().is_some())
199                || (browse && self.doc(id).directory_metadata_ref().is_none())
200            {
201                self.message = if browse {
202                    "browse requires a directory"
203                } else {
204                    "this view requires a regular file"
205                }
206                .into();
207                return;
208            }
209            self.finish_open(id, intent);
210            return;
211        }
212        let request = match self.worker_ids.allocate() {
213            Ok(request) => request,
214            Err(error) => {
215                self.message = error.message;
216                return;
217            }
218        };
219        let key = OpenKey {
220            path: path.clone(),
221            origin: self.current(),
222            revision: self.buf().revision(),
223            focus: self.focus_epoch,
224            intent,
225            selection,
226        };
227        if !matches!(key.intent, OpenIntent::CollectionSource { .. }) {
228            self.io.navigation = Some(request);
229        }
230        self.io.open.insert(request, key.clone());
231        self.message = format!("loading {path}");
232        let tx = self.io.tx.clone();
233        let ticket = Ticket { request, key };
234        match self.tape.request("io.open", &ticket) {
235            Ok(false) => return,
236            Ok(true) => {}
237            Err(error) => {
238                self.handle_io(IoEvent::Open(Box::new(Completion {
239                    ticket,
240                    outcome: Outcome::failed(FailureKind::Protocol, error.to_string()),
241                })));
242                return;
243            }
244        }
245        let client = self.remote_client();
246        let handle = worker::spawn(
247            "strop-open",
248            move |outcome| {
249                let _ = tx.send(IoEvent::Open(Box::new(Completion { ticket, outcome })));
250            },
251            move |cancel| match path {
252                FileTarget::Local(path) => match Buffer::open(&path) {
253                    Ok(buffer) => {
254                        let canonical = buffer
255                            .file_identity()
256                            .map_or_else(|| path.clone(), ToOwned::to_owned);
257                        Outcome::Success(Opened {
258                            document: Document::new(buffer),
259                            canonical: FileTarget::Local(canonical),
260                        })
261                    }
262                    Err(error) => Outcome::failed(FailureKind::Io, error.to_string()),
263                },
264                // Container documents open through :containers owned jobs;
265                // a container FileTarget never reads a local path.
266                FileTarget::Container { .. } => Outcome::failed(
267                    FailureKind::InvalidInput,
268                    "container documents open through :containers".to_string(),
269                ),
270                FileTarget::Remote(location) => match if browse {
271                    client
272                        .list(&location, &cancel)
273                        .map(strop_remote::RemoteResource::Directory)
274                } else {
275                    client.open(&location, selection, &cancel)
276                } {
277                    Ok(strop_remote::RemoteResource::File(snapshot)) => {
278                        let canonical = FileTarget::Remote(snapshot.file.clone().into());
279                        Outcome::Success(Opened {
280                            document: Document::remote_snapshot(*snapshot, selection),
281                            canonical,
282                        })
283                    }
284                    Ok(strop_remote::RemoteResource::Directory(snapshot)) => {
285                        if requires_file {
286                            return Outcome::failed(
287                                FailureKind::InvalidInput,
288                                "range/tail/follow requires a regular file",
289                            );
290                        }
291                        let canonical = FileTarget::Remote(snapshot.directory.clone().into());
292                        Outcome::Success(Opened {
293                            document: Document::remote_directory(snapshot),
294                            canonical,
295                        })
296                    }
297                    Err(error) if error.is_cancellation() => {
298                        Outcome::Cancelled(worker::CancelReason::OwnerClosed)
299                    }
300                    Err(error) => Outcome::failed(FailureKind::Io, error.to_string()),
301                },
302            },
303        );
304        self.worker_handles.insert(request, handle);
305    }
306
307    fn open_fresh(&self, key: &OpenKey) -> bool {
308        if self.finishing {
309            return false;
310        }
311        // Background work owns its operation generation, not the current focus.
312        if let OpenIntent::CollectionSource { owner } = key.intent {
313            return !self.docs.is_empty()
314                && self
315                    .collection_build
316                    .as_ref()
317                    .is_some_and(|build| build.owner == owner);
318        }
319        if let OpenIntent::LspLocation { context, .. } = &key.intent {
320            if !self.lsp_context_fresh(context) {
321                return false;
322            }
323        }
324        !self.docs.is_empty()
325            && self.current() == key.origin
326            && self.focus_epoch == key.focus
327            && self.buf().revision() == key.revision
328    }
329
330    /// Re-resolve every open document (config lands after the startup
331    /// buffer's construction in main).
332    pub fn reresolve_indents(&mut self) {
333        let ids: Vec<_> = self.docs.iter().map(|(id, _)| id).collect();
334        for id in ids {
335            self.resolve_indent_for(id);
336        }
337    }
338
339    /// Indent resolution (0051 R08): manual override → confident
340    /// detection → config, decided independently for style and width.
341    /// Overrides live on the document, so reloads and config refreshes
342    /// preserve them; a detected Tab style never dictates a width —
343    /// display width falls through to the configured/manual width.
344    pub(crate) fn resolve_indent_for(&mut self, document: DocumentId) {
345        use super::document::{Detection, IndentSource};
346        let detection = if self.config.indent_detect {
347            self.docs.get(document).and_then(|doc| doc.detection)
348        } else {
349            None
350        };
351        let configured = super::document::Indent {
352            style: self.config.indent_style,
353            width: self.config.tab_size,
354            style_source: IndentSource::Configured,
355            width_source: IndentSource::Configured,
356        };
357        let Some(doc) = self.docs.get_mut(document) else {
358            return;
359        };
360        let (style, style_source) = match (doc.indent_override.style, detection) {
361            (Some(style), _) => (style, IndentSource::Manual),
362            (None, Some(Detection::Tabs { .. })) => {
363                (crate::config::IndentStyle::Tabs, IndentSource::Detected)
364            }
365            (None, Some(Detection::Spaces { .. })) => {
366                (crate::config::IndentStyle::Spaces, IndentSource::Detected)
367            }
368            (None, _) => (configured.style, IndentSource::Configured),
369        };
370        let (width, width_source) = match (doc.indent_override.width, detection, style_source) {
371            (Some(width), _, _) => (width, IndentSource::Manual),
372            // A detected width is meaningful only with the detected
373            // spaces style it was measured on.
374            (None, Some(Detection::Spaces { width, .. }), IndentSource::Detected) => {
375                (width, IndentSource::Detected)
376            }
377            (None, _, _) => (configured.width, IndentSource::Configured),
378        };
379        doc.indent = super::document::Indent {
380            style,
381            width,
382            style_source,
383            width_source,
384        };
385    }
386
387    fn finish_open(&mut self, document: DocumentId, intent: OpenIntent) {
388        self.resolve_indent_for(document);
389        match intent {
390            OpenIntent::SearchHit(hit) => {
391                let Some(range) =
392                    super::picker::checked_hit_range(self.doc(document).buf.text(), &hit)
393                else {
394                    self.message = format!(
395                        "{}: search hit changed; refresh Search before opening it",
396                        self.doc(document).label(&self.cwd)
397                    );
398                    return;
399                };
400                self.jump_land(document, range.start.get());
401                self.discover_git();
402                self.lsp_maybe_attach();
403            }
404            OpenIntent::LspLocation { context, position } => {
405                self.finish_lsp_jump(document, position, context)
406            }
407            OpenIntent::Split { vertical } => self.split_document(vertical, document),
408            // Background opens never move focus; a pending collection
409            // build counts down and assembles when its sources land.
410            OpenIntent::CollectionSource { owner } => self.collection_source_ready(owner),
411            intent => {
412                self.switch_to(document);
413                self.set_head(0);
414                self.view_mut().view_top = 0;
415                match intent {
416                    OpenIntent::Switch { readonly: true } => self.buf_mut().readonly = true,
417                    OpenIntent::DirectoryParent { child } => {
418                        if let Some(line) = self
419                            .remote_directory()
420                            .and_then(|directory| directory.line_for(&child))
421                        {
422                            self.set_head(self.buf().line_start(line));
423                        }
424                    }
425                    OpenIntent::AtLine { line } => {
426                        self.set_head(
427                            self.buf()
428                                .line_start(line.get().min(self.buf().last_content_line())),
429                        );
430                        self.run_motion("^");
431                    }
432                    OpenIntent::RemoteView { view, line } => {
433                        if let Some(line) = line {
434                            self.set_head(
435                                self.buf()
436                                    .line_start(line.get().min(self.buf().last_content_line())),
437                            );
438                            self.run_motion("^");
439                        } else if view.follow_limit().is_some() {
440                            self.set_head(super::remote::follow::last_position(self.buf().text()));
441                        }
442                        if let Some(limit) = view.follow_limit() {
443                            self.start_remote_follow(document, limit);
444                        }
445                    }
446                    OpenIntent::Grep { line, column } => {
447                        let line = line.get().min(self.buf().last_content_line());
448                        let offset = self
449                            .buf()
450                            .line_start(line)
451                            .saturating_add(column.get())
452                            .min(self.buf().line_end(line));
453                        self.set_head(self.buf().clamp_boundary(offset));
454                        // grep/symbol landings use the 0051 §7
455                        // placement: center unless comfortably visible
456                        self.place_jump_target();
457                    }
458                    _ => {}
459                }
460                self.remember_remote_destination();
461                self.discover_git();
462                self.lsp_maybe_attach();
463            }
464        }
465    }
466
467    pub(crate) fn request_session_save(&mut self) {
468        let Some(work) = crate::session::capture_save(self) else {
469            return;
470        };
471        if self.io.session.is_some() {
472            // Serialized writes; newest queued capture replaces an unwritten one.
473            self.io.queued_session = Some(work);
474        } else {
475            self.start_session_save(work);
476        }
477    }
478
479    fn start_session_save(&mut self, work: crate::session::SaveRequest) {
480        let request = match self.worker_ids.allocate() {
481            Ok(request) => request,
482            Err(error) => {
483                self.message = error.message;
484                return;
485            }
486        };
487        self.io.session = Some(request);
488        match self.tape.request("io.session", &request) {
489            Ok(false) => return,
490            Ok(true) => {}
491            Err(error) => {
492                self.handle_io(IoEvent::Session {
493                    request,
494                    outcome: Outcome::failed(FailureKind::Protocol, error.to_string()),
495                });
496                return;
497            }
498        }
499        let tx = self.io.tx.clone();
500        let handle = worker::spawn(
501            "strop-session",
502            move |outcome| {
503                let _ = tx.send(IoEvent::Session { request, outcome });
504            },
505            move |_| match work.persist() {
506                Ok(()) => Outcome::Success(()),
507                Err(error) => Outcome::failed(FailureKind::Io, error.to_string()),
508            },
509        );
510        self.worker_handles.insert(request, handle);
511    }
512
513    pub fn handle_io(&mut self, event: IoEvent) {
514        super::trace::services::io(&event);
515        match event {
516            IoEvent::Native(completion) => self.handle_native(*completion),
517            IoEvent::Remote(event) => self.handle_remote_event(event),
518            IoEvent::Review(completion) => self.handle_review_prepared(*completion),
519            IoEvent::Open(completion) => {
520                let request = completion.ticket.request;
521                if self.io.open.get(&request) != Some(&completion.ticket.key) {
522                    return;
523                }
524                let Some(key) = self.io.open.remove(&request) else {
525                    return;
526                };
527                self.worker_handles.remove(&request);
528                if self.io.navigation == Some(request) {
529                    self.io.navigation = None;
530                }
531                if !self.open_fresh(&key) {
532                    return;
533                }
534                match completion.outcome {
535                    Outcome::Success(mut opened) => {
536                        if matches!(key.intent, OpenIntent::Refresh) {
537                            self.revoke_remote_write(key.origin);
538                            self.finish_remote_refresh(key.origin, opened.document);
539                            return;
540                        }
541                        opened.document.set_return_point(self.jump_record());
542                        let existing = self.docs.iter().find_map(|(id, document)| {
543                            (document.matches_target(&opened.canonical)
544                                && document
545                                    .remote_metadata()
546                                    .is_none_or(|source| source.selection == key.selection))
547                            .then_some(id)
548                        });
549                        let id = if let Some(id) = existing {
550                            if matches!(
551                                key.intent,
552                                OpenIntent::Browse | OpenIntent::RemoteDestination
553                            ) && self.doc(id).directory_metadata_ref().is_some()
554                            {
555                                if let Err(error) =
556                                    self.publish_remote_snapshot(id, opened.document, false)
557                                {
558                                    self.message = error.to_string();
559                                    return;
560                                }
561                            }
562                            id
563                        } else {
564                            if let OpenIntent::SearchHit(hit) = &key.intent {
565                                if super::picker::checked_hit_range(opened.document.buf.text(), hit)
566                                    .is_none()
567                                {
568                                    self.message = format!(
569                                        "{}: search hit changed; refresh Search before opening it",
570                                        key.path
571                                    );
572                                    return;
573                                }
574                            }
575                            // Background loads never steal the view: the
576                            // pristine scratch stays until a foreground
577                            // open or the built collection replaces it.
578                            let takes_focus =
579                                !matches!(key.intent, OpenIntent::CollectionSource { .. });
580                            // A first open on an endpoint binds its workspace
581                            // context (0042 slice 2); rebinds are idempotent.
582                            let endpoint = opened
583                                .document
584                                .remote_metadata()
585                                .map(|source| source.file.endpoint().clone())
586                                .or_else(|| {
587                                    opened
588                                        .document
589                                        .directory_metadata_ref()
590                                        .map(|directory| directory.directory.endpoint().clone())
591                                });
592                            if let Some(endpoint) = endpoint {
593                                self.workspaces
594                                    .bind(strop_workspace::Filesystem::Remote(endpoint), None);
595                            }
596                            let id = self.docs.insert(opened.document);
597                            if takes_focus {
598                                self.drop_stale_scratch(id);
599                            }
600                            self.generation += 1;
601                            self.mru.push(id);
602                            id
603                        };
604                        self.message.clear();
605                        self.finish_open(id, key.intent);
606                    }
607                    Outcome::Failed { failure, .. } => {
608                        if let OpenIntent::CollectionSource { owner } = key.intent {
609                            self.collection_source_ready(owner);
610                        }
611                        self.message = format!("open {}: {}", key.path, failure.message);
612                    }
613                    Outcome::Cancelled(_) => {}
614                }
615            }
616            IoEvent::Save(completion) => {
617                let request = completion.ticket.request;
618                if self.io.saves.get(&completion.ticket.key.document) != Some(&completion.ticket) {
619                    return;
620                }
621                let key = completion.ticket.key;
622                self.io.saves.remove(&key.document);
623                self.worker_handles.remove(&request);
624                match completion.outcome {
625                    Outcome::Success(receipt) => {
626                        let Some(document) = self.docs.get_mut(key.document) else {
627                            self.message = "snapshot written; source buffer closed".into();
628                            self.finish_save_feedback(key.document);
629                            self.collection_save_progress(key.document, false);
630                            return;
631                        };
632                        let previous_path = document.buf.path.clone();
633                        let saved = document.buf.accept_save(receipt);
634                        let renamed = previous_path != document.buf.path;
635                        if renamed {
636                            self.lsp_close_document(key.document);
637                            if !self.docs.is_empty() && self.current() == key.document {
638                                self.lsp_maybe_attach();
639                            }
640                        }
641                        self.message = if saved {
642                            "written"
643                        } else {
644                            "snapshot written; newer edits remain unsaved"
645                        }
646                        .into();
647                        self.request_session_save();
648                        self.collection_save_progress(key.document, saved);
649                        if saved
650                            && key.close
651                            && !self.docs.is_empty()
652                            && self.current() == key.document
653                            && self.focus_epoch == key.focus
654                        {
655                            self.close_pane_or_buffer(false);
656                        }
657                    }
658                    Outcome::Failed { failure, .. } => {
659                        self.collection_save_progress(key.document, false);
660                        self.message = format!("write failed: {}", failure.message)
661                    }
662                    Outcome::Cancelled(_) => {
663                        self.collection_save_progress(key.document, false);
664                        self.message = "write cancelled".into();
665                    }
666                }
667                self.finish_save_feedback(key.document);
668            }
669            IoEvent::Session { request, outcome } => {
670                if self.io.session != Some(request) {
671                    return;
672                }
673                self.io.session = None;
674                self.worker_handles.remove(&request);
675                if let Outcome::Failed { failure, .. } = outcome {
676                    self.message = format!("session save failed: {}", failure.message);
677                    self.io.session_error = Some(self.message.clone());
678                }
679                if let Some(work) = self.io.queued_session.take() {
680                    self.start_session_save(work);
681                }
682            }
683        }
684    }
685
686    pub fn io_pending(&self) -> bool {
687        !self.io.open.is_empty()
688            || self.review.preparing.is_some()
689            || !self.io.saves.is_empty()
690            || self.io.session.is_some()
691            || !self.io.native.is_empty()
692            || self.remote_work_pending()
693    }
694}
695
696impl Editor {
697    pub(crate) fn io_write_pending(&self, request: WorkerId) -> bool {
698        self.io.session == Some(request)
699            || self.remote_write_pending(request)
700            || self.destination_write_pending(request)
701            || self
702                .io
703                .saves
704                .values()
705                .any(|ticket| ticket.request == request)
706            || self.io.native.get(&request).is_some_and(|key| {
707                matches!(
708                    key.operation,
709                    native::Operation::Trust { .. } | native::Operation::TrustRemote { .. }
710                )
711            })
712    }
713    pub fn io_status(&self) -> Option<&'static str> {
714        if let Some(status) = self.remote_write_status() {
715            return Some(status);
716        }
717        if !self.io.saves.is_empty() {
718            Some("saving")
719        } else if !self.io.open.is_empty() {
720            Some("loading")
721        } else {
722            None
723        }
724    }
725
726    pub(crate) fn remote_refresh_pending(&self, document: DocumentId) -> bool {
727        self.io
728            .open
729            .values()
730            .any(|key| key.origin == document && matches!(key.intent, OpenIntent::Refresh))
731    }
732}
733
734impl IoState {
735    /// In-flight native tickets — tests answer a tape-suppressed
736    /// launch by feeding `handle_io` a crafted completion.
737    #[cfg(test)]
738    pub(crate) fn native_tickets(&self) -> Vec<Ticket<native::NativeKey>> {
739        self.native
740            .iter()
741            .map(|(request, key)| Ticket {
742                request: *request,
743                key: key.clone(),
744            })
745            .collect()
746    }
747}