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        if matches!(key.intent, OpenIntent::Replace { .. }) {
304            return true;
305        }
306        if let OpenIntent::LspLocation { context, .. } = &key.intent {
307            if !self.lsp_context_fresh(context) {
308                return false;
309            }
310        }
311        !self.docs.is_empty()
312            && self.current() == key.origin
313            && self.focus_epoch == key.focus
314            && self.buf().revision() == key.revision
315    }
316
317    fn finish_open(&mut self, document: DocumentId, intent: OpenIntent) {
318        match intent {
319            OpenIntent::LspLocation { context, position } => {
320                self.finish_lsp_jump(document, position, context)
321            }
322            OpenIntent::Replace { hits, replacement } => {
323                let (_, applied, stale) = self.replace_in_buffer(document, &hits, &replacement);
324                self.message = format!("replaced {applied}; {stale} stale matches skipped");
325                if applied > 0 {
326                    self.request_save_document(document, None, true, false);
327                }
328            }
329            OpenIntent::Split { vertical } => self.split_document(vertical, document),
330            // Background opens never move focus; a pending collection
331            // build counts down and assembles when its sources land.
332            OpenIntent::Background => self.collection_source_ready(document),
333            intent => {
334                self.switch_to(document);
335                self.set_head(0);
336                self.view_mut().view_top = 0;
337                match intent {
338                    OpenIntent::Switch { readonly: true } => self.buf_mut().readonly = true,
339                    OpenIntent::DirectoryParent { child } => {
340                        if let Some(line) = self
341                            .remote_directory()
342                            .and_then(|directory| directory.line_for(&child))
343                        {
344                            self.set_head(self.buf().line_start(line));
345                        }
346                    }
347                    OpenIntent::AtLine { line } => {
348                        self.set_head(
349                            self.buf()
350                                .line_start(line.get().min(self.buf().last_content_line())),
351                        );
352                        self.run_motion("^");
353                    }
354                    OpenIntent::RemoteView { view, line } => {
355                        if let Some(line) = line {
356                            self.set_head(
357                                self.buf()
358                                    .line_start(line.get().min(self.buf().last_content_line())),
359                            );
360                            self.run_motion("^");
361                        } else if view.follow_limit().is_some() {
362                            self.set_head(super::remote::follow::last_position(self.buf().text()));
363                        }
364                        if let Some(limit) = view.follow_limit() {
365                            self.start_remote_follow(document, limit);
366                        }
367                    }
368                    OpenIntent::Grep { line, column } => {
369                        let line = line.get().min(self.buf().last_content_line());
370                        let offset = self
371                            .buf()
372                            .line_start(line)
373                            .saturating_add(column.get())
374                            .min(self.buf().line_end(line));
375                        self.set_head(self.buf().clamp_boundary(offset));
376                    }
377                    _ => {}
378                }
379                self.remember_remote_destination();
380                self.discover_git();
381                self.lsp_maybe_attach();
382            }
383        }
384    }
385
386    pub fn request_save(&mut self, target: Option<PathBuf>, force: bool, close: bool) {
387        self.request_save_document(self.current(), target, force, close);
388    }
389
390    pub(crate) fn request_save_document(
391        &mut self,
392        document: DocumentId,
393        target: Option<PathBuf>,
394        force: bool,
395        close: bool,
396    ) {
397        if self.docs.get(document).is_some_and(|doc| {
398            matches!(
399                doc.source,
400                super::document::DocumentSource::Remote(_)
401                    | super::document::DocumentSource::RemoteDirectory(_)
402            )
403        }) {
404            self.request_remote_save(document, target, force, close);
405            return;
406        }
407        if target
408            .as_ref()
409            .and_then(|path| path.to_str())
410            .is_some_and(|path| path.starts_with("ssh://"))
411        {
412            self.message = "remote save-as is unsupported; no local fallback".into();
413            return;
414        }
415        if self.io.saves.contains_key(&document) {
416            self.message = "write already in progress".into();
417            return;
418        }
419        let Some(buffer) = self.docs.get(document).map(|doc| &doc.buf) else {
420            return;
421        };
422        let revision = buffer.revision();
423        let target = target.map(|path| self.cwd.join(path));
424        let work = match buffer.prepare_save(target.clone(), force) {
425            Ok(work) => work,
426            Err(error) => {
427                self.message = format!("write failed: {error}");
428                return;
429            }
430        };
431        let request = match self.worker_ids.allocate() {
432            Ok(request) => request,
433            Err(error) => {
434                self.message = error.message;
435                return;
436            }
437        };
438        let ticket = Ticket {
439            request,
440            key: SaveKey {
441                document,
442                revision,
443                focus: self.focus_epoch,
444                close,
445                target,
446                force,
447            },
448        };
449        self.io.saves.insert(document, ticket.clone());
450        self.message = "saving".into();
451        match self.tape.request("io.save", &ticket) {
452            Ok(false) => return,
453            Ok(true) => {}
454            Err(error) => {
455                self.handle_io(IoEvent::Save(Box::new(Completion {
456                    ticket,
457                    outcome: Outcome::failed(FailureKind::Protocol, error.to_string()),
458                })));
459                return;
460            }
461        }
462        let tx = self.io.tx.clone();
463        let handle = worker::spawn(
464            "strop-save",
465            move |outcome| {
466                let _ = tx.send(IoEvent::Save(Box::new(Completion { ticket, outcome })));
467            },
468            move |_| match work.execute() {
469                Ok(receipt) => Outcome::Success(receipt),
470                Err(error) => Outcome::failed(FailureKind::Io, error.to_string()),
471            },
472        );
473        self.worker_handles.insert(request, handle);
474    }
475
476    pub(crate) fn request_session_save(&mut self) {
477        let Some(work) = crate::session::capture_save(self) else {
478            return;
479        };
480        if self.io.session.is_some() {
481            // Serialized writes; newest queued capture replaces an unwritten one.
482            self.io.queued_session = Some(work);
483        } else {
484            self.start_session_save(work);
485        }
486    }
487
488    fn start_session_save(&mut self, work: crate::session::SaveRequest) {
489        let request = match self.worker_ids.allocate() {
490            Ok(request) => request,
491            Err(error) => {
492                self.message = error.message;
493                return;
494            }
495        };
496        self.io.session = Some(request);
497        match self.tape.request("io.session", &request) {
498            Ok(false) => return,
499            Ok(true) => {}
500            Err(error) => {
501                self.handle_io(IoEvent::Session {
502                    request,
503                    outcome: Outcome::failed(FailureKind::Protocol, error.to_string()),
504                });
505                return;
506            }
507        }
508        let tx = self.io.tx.clone();
509        let handle = worker::spawn(
510            "strop-session",
511            move |outcome| {
512                let _ = tx.send(IoEvent::Session { request, outcome });
513            },
514            move |_| match work.persist() {
515                Ok(()) => Outcome::Success(()),
516                Err(error) => Outcome::failed(FailureKind::Io, error.to_string()),
517            },
518        );
519        self.worker_handles.insert(request, handle);
520    }
521
522    pub fn handle_io(&mut self, event: IoEvent) {
523        super::trace::services::io(&event);
524        match event {
525            IoEvent::Native(completion) => self.handle_native(*completion),
526            IoEvent::Remote(event) => self.handle_remote_event(event),
527            IoEvent::Open(completion) => {
528                let request = completion.ticket.request;
529                if self.io.open.get(&request) != Some(&completion.ticket.key) {
530                    return;
531                }
532                let Some(key) = self.io.open.remove(&request) else {
533                    return;
534                };
535                self.worker_handles.remove(&request);
536                if self.io.navigation == Some(request) {
537                    self.io.navigation = None;
538                }
539                if !self.open_fresh(&key) {
540                    return;
541                }
542                match completion.outcome {
543                    Outcome::Success(mut opened) => {
544                        if matches!(key.intent, OpenIntent::Refresh) {
545                            self.revoke_remote_write(key.origin);
546                            self.finish_remote_refresh(key.origin, opened.document);
547                            return;
548                        }
549                        opened
550                            .document
551                            .set_return_point(super::document::ReturnPoint {
552                                buffer: key.origin,
553                                cursor: self.head(),
554                                view_top: self.view_top(),
555                                hscroll: self.view().hscroll,
556                            });
557                        let existing = self.docs.iter().find_map(|(id, document)| {
558                            (document.matches_target(&opened.canonical)
559                                && document
560                                    .remote_metadata()
561                                    .is_none_or(|source| source.selection == key.selection))
562                            .then_some(id)
563                        });
564                        let id = if let Some(id) = existing {
565                            if matches!(
566                                key.intent,
567                                OpenIntent::Browse | OpenIntent::RemoteDestination
568                            ) && self.doc(id).directory_metadata_ref().is_some()
569                            {
570                                if let Err(error) =
571                                    self.publish_remote_snapshot(id, opened.document, false)
572                                {
573                                    self.message = error.to_string();
574                                    return;
575                                }
576                            }
577                            id
578                        } else {
579                            // A first open on an endpoint binds its workspace
580                            // context (0042 slice 2); rebinds are idempotent.
581                            let endpoint = opened
582                                .document
583                                .remote_metadata()
584                                .map(|source| source.file.endpoint().clone())
585                                .or_else(|| {
586                                    opened
587                                        .document
588                                        .directory_metadata_ref()
589                                        .map(|directory| directory.directory.endpoint().clone())
590                                });
591                            if let Some(endpoint) = endpoint {
592                                self.workspaces
593                                    .bind(strop_workspace::Filesystem::Remote(endpoint), None);
594                            }
595                            let id = self.docs.insert(opened.document);
596                            self.drop_stale_scratch(id);
597                            self.generation += 1;
598                            self.mru.push(id);
599                            id
600                        };
601                        self.message.clear();
602                        self.finish_open(id, key.intent);
603                    }
604                    Outcome::Failed { failure, .. } => {
605                        if matches!(key.intent, OpenIntent::Background) {
606                            self.collection_source_ready(key.origin);
607                        }
608                        self.message = format!("open {}: {}", key.path, failure.message)
609                    }
610                    Outcome::Cancelled(_) => {}
611                }
612            }
613            IoEvent::Save(completion) => {
614                let request = completion.ticket.request;
615                if self.io.saves.get(&completion.ticket.key.document) != Some(&completion.ticket) {
616                    return;
617                }
618                let key = completion.ticket.key;
619                self.io.saves.remove(&key.document);
620                self.worker_handles.remove(&request);
621                match completion.outcome {
622                    Outcome::Success(receipt) => {
623                        let Some(document) = self.docs.get_mut(key.document) else {
624                            return;
625                        };
626                        let previous_path = document.buf.path.clone();
627                        let saved = document.buf.accept_save(receipt);
628                        let renamed = previous_path != document.buf.path;
629                        if renamed {
630                            self.lsp_close_document(key.document);
631                            if !self.docs.is_empty() && self.current() == key.document {
632                                self.lsp_maybe_attach();
633                            }
634                        }
635                        self.message = if saved {
636                            "written"
637                        } else {
638                            "snapshot written; newer edits remain unsaved"
639                        }
640                        .into();
641                        self.request_session_save();
642                        if saved
643                            && key.close
644                            && !self.docs.is_empty()
645                            && self.current() == key.document
646                            && self.focus_epoch == key.focus
647                        {
648                            self.close_pane_or_buffer(false);
649                        }
650                    }
651                    Outcome::Failed { failure, .. } => {
652                        self.message = format!("write failed: {}", failure.message)
653                    }
654                    Outcome::Cancelled(_) => self.message = "write cancelled".into(),
655                }
656            }
657            IoEvent::Session { request, outcome } => {
658                if self.io.session != Some(request) {
659                    return;
660                }
661                self.io.session = None;
662                self.worker_handles.remove(&request);
663                if let Outcome::Failed { failure, .. } = outcome {
664                    self.message = format!("session save failed: {}", failure.message);
665                    self.io.session_error = Some(self.message.clone());
666                }
667                if let Some(work) = self.io.queued_session.take() {
668                    self.start_session_save(work);
669                }
670            }
671        }
672    }
673
674    pub fn io_pending(&self) -> bool {
675        !self.io.open.is_empty()
676            || !self.io.saves.is_empty()
677            || self.io.session.is_some()
678            || !self.io.native.is_empty()
679            || self.remote_work_pending()
680    }
681}
682
683impl Editor {
684    pub(crate) fn io_write_pending(&self, request: WorkerId) -> bool {
685        self.io.session == Some(request)
686            || self.remote_write_pending(request)
687            || self.destination_write_pending(request)
688            || self
689                .io
690                .saves
691                .values()
692                .any(|ticket| ticket.request == request)
693            || self.io.native.get(&request).is_some_and(|key| {
694                matches!(
695                    key.operation,
696                    native::Operation::Trust { .. } | native::Operation::TrustRemote { .. }
697                )
698            })
699    }
700    pub fn io_status(&self) -> Option<&'static str> {
701        if let Some(status) = self.remote_write_status() {
702            return Some(status);
703        }
704        if !self.io.saves.is_empty() {
705            Some("saving")
706        } else if !self.io.open.is_empty() {
707            Some("loading")
708        } else {
709            None
710        }
711    }
712
713    pub(crate) fn remote_refresh_pending(&self, document: DocumentId) -> bool {
714        self.io
715            .open
716            .values()
717            .any(|key| key.origin == document && matches!(key.intent, OpenIntent::Refresh))
718    }
719}
720
721impl IoState {
722    /// In-flight native tickets — tests answer a tape-suppressed
723    /// launch by feeding `handle_io` a crafted completion.
724    #[cfg(test)]
725    pub(crate) fn native_tickets(&self) -> Vec<Ticket<native::NativeKey>> {
726        self.native
727            .iter()
728            .map(|(request, key)| Ticket {
729                request: *request,
730                key: key.clone(),
731            })
732            .collect()
733    }
734}