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