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!(
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 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 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 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 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 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 let takes_focus = !matches!(key.intent, OpenIntent::Background);
631 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 #[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}