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