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