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