1mod 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 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 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 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 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 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 #[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}