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 SearchHit(super::picker::ReplacementHit),
43 LspLocation {
44 context: strop_lsp::ReplyContext,
45 position: strop_lsp::ServerPosition,
46 },
47 CollectionSource {
50 owner: WorkerId,
51 },
52}
53impl OpenIntent {
54 fn requires_file(&self) -> bool {
55 match self {
56 Self::AtLine { .. }
57 | Self::Grep { .. }
58 | Self::SearchHit(_)
59 | Self::LspLocation { .. } => true,
60 Self::RemoteView { view, line } => {
61 line.is_some() || *view != super::remote::RemoteView::default()
62 }
63 _ => false,
64 }
65 }
66}
67
68#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
69pub struct OpenKey {
70 pub path: FileTarget,
71 pub origin: DocumentId,
72 pub revision: BufferRevision,
73 pub focus: u64,
74 pub intent: OpenIntent,
75 pub selection: strop_remote::ReadSelection,
76}
77
78pub struct Opened {
79 pub document: Document,
80 pub canonical: FileTarget,
81}
82
83#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
84pub struct SaveKey {
85 pub document: DocumentId,
86 pub revision: BufferRevision,
87 pub focus: u64,
88 pub close: bool,
89 #[serde(with = "strop_core::path_serde::option")]
90 pub target: Option<PathBuf>,
91 pub force: bool,
92}
93
94#[derive(serde::Serialize, serde::Deserialize)]
95pub enum IoEvent {
96 Open(Box<Completion<OpenKey, Opened>>),
97 Save(Box<Completion<SaveKey, SaveReceipt>>),
98 Native(Box<Completion<native::NativeKey, native::NativeResult>>),
99 Remote(super::remote::RemoteEvent),
100 Review(
101 Box<
102 Completion<
103 super::changes::review::prepare::PreparationKey,
104 super::changes::review::prepare::PreparedReview,
105 >,
106 >,
107 ),
108 Session {
109 request: WorkerId,
110 outcome: Outcome<()>,
111 },
112}
113
114pub struct IoState {
115 pub tx: Sender<IoEvent>,
116 pub rx: Option<Receiver<IoEvent>>,
117 pub open: HashMap<WorkerId, OpenKey>,
118 navigation: Option<WorkerId>,
119 saves: HashMap<DocumentId, Ticket<SaveKey>>,
120 session: Option<WorkerId>,
121 queued_session: Option<crate::session::SaveRequest>,
122 native: HashMap<WorkerId, native::NativeKey>,
123 pub session_error: Option<String>,
124 pub(crate) format_warnings: HashMap<DocumentId, String>,
125}
126
127impl Default for IoState {
128 fn default() -> Self {
129 let (tx, rx) = mpsc::channel();
130 Self {
131 tx,
132 rx: Some(rx),
133 open: HashMap::new(),
134 navigation: None,
135 saves: HashMap::new(),
136 session: None,
137 queued_session: None,
138 native: HashMap::new(),
139 session_error: None,
140 format_warnings: HashMap::new(),
141 }
142 }
143}
144
145impl Editor {
146 pub fn request_open(&mut self, path: PathBuf, intent: OpenIntent) {
147 self.request_target(FileTarget::Local(path), intent);
148 }
149
150 pub fn request_target(&mut self, target: FileTarget, intent: OpenIntent) {
151 if matches!(intent, OpenIntent::Refresh) && self.remote_write_blocks_refresh(self.current())
152 {
153 self.message =
154 "remote save pending or unconfirmed; settle or :remote verify before refresh"
155 .into();
156 return;
157 }
158 let selection = match &intent {
159 OpenIntent::RemoteView { view, .. } => view.selection(),
160 OpenIntent::Refresh => self
161 .cur()
162 .remote_metadata()
163 .map_or(strop_remote::ReadSelection::Full, |source| source.selection),
164 _ => strop_remote::ReadSelection::Full,
165 };
166 let requires_file = intent.requires_file();
167 let browse = matches!(
168 intent,
169 OpenIntent::Browse | OpenIntent::DirectoryParent { .. }
170 );
171 if matches!(target, FileTarget::Local(_))
172 && (browse
173 || matches!(
174 intent,
175 OpenIntent::RemoteView { .. } | OpenIntent::RemoteDestination
176 ))
177 {
178 self.message = "range/tail/follow views require a remote target".into();
179 return;
180 }
181 let path = match target {
182 FileTarget::Local(path) => FileTarget::Local(self.cwd.join(path)),
183 remote => remote,
184 };
185 self.cancel_open(worker::CancelReason::Superseded);
186 let existing = self.docs.iter().find_map(|(id, document)| {
187 (document.matches_target(&path)
188 && document
189 .remote_metadata()
190 .is_none_or(|source| source.selection == selection))
191 .then_some(id)
192 });
193 if let Some(id) = existing.filter(|&id| {
194 !matches!(intent, OpenIntent::Refresh)
195 && (!matches!(intent, OpenIntent::Browse | OpenIntent::RemoteDestination)
196 || self.doc(id).directory_metadata_ref().is_none())
197 }) {
198 if (requires_file && self.doc(id).directory_metadata_ref().is_some())
199 || (browse && self.doc(id).directory_metadata_ref().is_none())
200 {
201 self.message = if browse {
202 "browse requires a directory"
203 } else {
204 "this view requires a regular file"
205 }
206 .into();
207 return;
208 }
209 self.finish_open(id, intent);
210 return;
211 }
212 let request = match self.worker_ids.allocate() {
213 Ok(request) => request,
214 Err(error) => {
215 self.message = error.message;
216 return;
217 }
218 };
219 let key = OpenKey {
220 path: path.clone(),
221 origin: self.current(),
222 revision: self.buf().revision(),
223 focus: self.focus_epoch,
224 intent,
225 selection,
226 };
227 if !matches!(key.intent, OpenIntent::CollectionSource { .. }) {
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 if let OpenIntent::CollectionSource { owner } = key.intent {
313 return !self.docs.is_empty()
314 && self
315 .collection_build
316 .as_ref()
317 .is_some_and(|build| build.owner == owner);
318 }
319 if let OpenIntent::LspLocation { context, .. } = &key.intent {
320 if !self.lsp_context_fresh(context) {
321 return false;
322 }
323 }
324 !self.docs.is_empty()
325 && self.current() == key.origin
326 && self.focus_epoch == key.focus
327 && self.buf().revision() == key.revision
328 }
329
330 pub fn reresolve_indents(&mut self) {
333 let ids: Vec<_> = self.docs.iter().map(|(id, _)| id).collect();
334 for id in ids {
335 self.resolve_indent_for(id);
336 }
337 }
338
339 pub(crate) fn resolve_indent_for(&mut self, document: DocumentId) {
345 use super::document::{Detection, IndentSource};
346 let detection = if self.config.indent_detect {
347 self.docs.get(document).and_then(|doc| doc.detection)
348 } else {
349 None
350 };
351 let configured = super::document::Indent {
352 style: self.config.indent_style,
353 width: self.config.tab_size,
354 style_source: IndentSource::Configured,
355 width_source: IndentSource::Configured,
356 };
357 let Some(doc) = self.docs.get_mut(document) else {
358 return;
359 };
360 let (style, style_source) = match (doc.indent_override.style, detection) {
361 (Some(style), _) => (style, IndentSource::Manual),
362 (None, Some(Detection::Tabs { .. })) => {
363 (crate::config::IndentStyle::Tabs, IndentSource::Detected)
364 }
365 (None, Some(Detection::Spaces { .. })) => {
366 (crate::config::IndentStyle::Spaces, IndentSource::Detected)
367 }
368 (None, _) => (configured.style, IndentSource::Configured),
369 };
370 let (width, width_source) = match (doc.indent_override.width, detection, style_source) {
371 (Some(width), _, _) => (width, IndentSource::Manual),
372 (None, Some(Detection::Spaces { width, .. }), IndentSource::Detected) => {
375 (width, IndentSource::Detected)
376 }
377 (None, _, _) => (configured.width, IndentSource::Configured),
378 };
379 doc.indent = super::document::Indent {
380 style,
381 width,
382 style_source,
383 width_source,
384 };
385 }
386
387 fn finish_open(&mut self, document: DocumentId, intent: OpenIntent) {
388 self.resolve_indent_for(document);
389 match intent {
390 OpenIntent::SearchHit(hit) => {
391 let Some(range) =
392 super::picker::checked_hit_range(self.doc(document).buf.text(), &hit)
393 else {
394 self.message = format!(
395 "{}: search hit changed; refresh Search before opening it",
396 self.doc(document).label(&self.cwd)
397 );
398 return;
399 };
400 self.jump_land(document, range.start.get());
401 self.discover_git();
402 self.lsp_maybe_attach();
403 }
404 OpenIntent::LspLocation { context, position } => {
405 self.finish_lsp_jump(document, position, context)
406 }
407 OpenIntent::Split { vertical } => self.split_document(vertical, document),
408 OpenIntent::CollectionSource { owner } => self.collection_source_ready(owner),
411 intent => {
412 self.switch_to(document);
413 self.set_head(0);
414 self.view_mut().view_top = 0;
415 match intent {
416 OpenIntent::Switch { readonly: true } => self.buf_mut().readonly = true,
417 OpenIntent::DirectoryParent { child } => {
418 if let Some(line) = self
419 .remote_directory()
420 .and_then(|directory| directory.line_for(&child))
421 {
422 self.set_head(self.buf().line_start(line));
423 }
424 }
425 OpenIntent::AtLine { line } => {
426 self.set_head(
427 self.buf()
428 .line_start(line.get().min(self.buf().last_content_line())),
429 );
430 self.run_motion("^");
431 }
432 OpenIntent::RemoteView { view, line } => {
433 if let Some(line) = line {
434 self.set_head(
435 self.buf()
436 .line_start(line.get().min(self.buf().last_content_line())),
437 );
438 self.run_motion("^");
439 } else if view.follow_limit().is_some() {
440 self.set_head(super::remote::follow::last_position(self.buf().text()));
441 }
442 if let Some(limit) = view.follow_limit() {
443 self.start_remote_follow(document, limit);
444 }
445 }
446 OpenIntent::Grep { line, column } => {
447 let line = line.get().min(self.buf().last_content_line());
448 let offset = self
449 .buf()
450 .line_start(line)
451 .saturating_add(column.get())
452 .min(self.buf().line_end(line));
453 self.set_head(self.buf().clamp_boundary(offset));
454 self.place_jump_target();
457 }
458 _ => {}
459 }
460 self.remember_remote_destination();
461 self.discover_git();
462 self.lsp_maybe_attach();
463 }
464 }
465 }
466
467 pub(crate) fn request_session_save(&mut self) {
468 let Some(work) = crate::session::capture_save(self) else {
469 return;
470 };
471 if self.io.session.is_some() {
472 self.io.queued_session = Some(work);
474 } else {
475 self.start_session_save(work);
476 }
477 }
478
479 fn start_session_save(&mut self, work: crate::session::SaveRequest) {
480 let request = match self.worker_ids.allocate() {
481 Ok(request) => request,
482 Err(error) => {
483 self.message = error.message;
484 return;
485 }
486 };
487 self.io.session = Some(request);
488 match self.tape.request("io.session", &request) {
489 Ok(false) => return,
490 Ok(true) => {}
491 Err(error) => {
492 self.handle_io(IoEvent::Session {
493 request,
494 outcome: Outcome::failed(FailureKind::Protocol, error.to_string()),
495 });
496 return;
497 }
498 }
499 let tx = self.io.tx.clone();
500 let handle = worker::spawn(
501 "strop-session",
502 move |outcome| {
503 let _ = tx.send(IoEvent::Session { request, outcome });
504 },
505 move |_| match work.persist() {
506 Ok(()) => Outcome::Success(()),
507 Err(error) => Outcome::failed(FailureKind::Io, error.to_string()),
508 },
509 );
510 self.worker_handles.insert(request, handle);
511 }
512
513 pub fn handle_io(&mut self, event: IoEvent) {
514 super::trace::services::io(&event);
515 match event {
516 IoEvent::Native(completion) => self.handle_native(*completion),
517 IoEvent::Remote(event) => self.handle_remote_event(event),
518 IoEvent::Review(completion) => self.handle_review_prepared(*completion),
519 IoEvent::Open(completion) => {
520 let request = completion.ticket.request;
521 if self.io.open.get(&request) != Some(&completion.ticket.key) {
522 return;
523 }
524 let Some(key) = self.io.open.remove(&request) else {
525 return;
526 };
527 self.worker_handles.remove(&request);
528 if self.io.navigation == Some(request) {
529 self.io.navigation = None;
530 }
531 if !self.open_fresh(&key) {
532 return;
533 }
534 match completion.outcome {
535 Outcome::Success(mut opened) => {
536 if matches!(key.intent, OpenIntent::Refresh) {
537 self.revoke_remote_write(key.origin);
538 self.finish_remote_refresh(key.origin, opened.document);
539 return;
540 }
541 opened.document.set_return_point(self.jump_record());
542 let existing = self.docs.iter().find_map(|(id, document)| {
543 (document.matches_target(&opened.canonical)
544 && document
545 .remote_metadata()
546 .is_none_or(|source| source.selection == key.selection))
547 .then_some(id)
548 });
549 let id = if let Some(id) = existing {
550 if matches!(
551 key.intent,
552 OpenIntent::Browse | OpenIntent::RemoteDestination
553 ) && self.doc(id).directory_metadata_ref().is_some()
554 {
555 if let Err(error) =
556 self.publish_remote_snapshot(id, opened.document, false)
557 {
558 self.message = error.to_string();
559 return;
560 }
561 }
562 id
563 } else {
564 if let OpenIntent::SearchHit(hit) = &key.intent {
565 if super::picker::checked_hit_range(opened.document.buf.text(), hit)
566 .is_none()
567 {
568 self.message = format!(
569 "{}: search hit changed; refresh Search before opening it",
570 key.path
571 );
572 return;
573 }
574 }
575 let takes_focus =
579 !matches!(key.intent, OpenIntent::CollectionSource { .. });
580 let endpoint = opened
583 .document
584 .remote_metadata()
585 .map(|source| source.file.endpoint().clone())
586 .or_else(|| {
587 opened
588 .document
589 .directory_metadata_ref()
590 .map(|directory| directory.directory.endpoint().clone())
591 });
592 if let Some(endpoint) = endpoint {
593 self.workspaces
594 .bind(strop_workspace::Filesystem::Remote(endpoint), None);
595 }
596 let id = self.docs.insert(opened.document);
597 if takes_focus {
598 self.drop_stale_scratch(id);
599 }
600 self.generation += 1;
601 self.mru.push(id);
602 id
603 };
604 self.message.clear();
605 self.finish_open(id, key.intent);
606 }
607 Outcome::Failed { failure, .. } => {
608 if let OpenIntent::CollectionSource { owner } = key.intent {
609 self.collection_source_ready(owner);
610 }
611 self.message = format!("open {}: {}", key.path, failure.message);
612 }
613 Outcome::Cancelled(_) => {}
614 }
615 }
616 IoEvent::Save(completion) => {
617 let request = completion.ticket.request;
618 if self.io.saves.get(&completion.ticket.key.document) != Some(&completion.ticket) {
619 return;
620 }
621 let key = completion.ticket.key;
622 self.io.saves.remove(&key.document);
623 self.worker_handles.remove(&request);
624 match completion.outcome {
625 Outcome::Success(receipt) => {
626 let Some(document) = self.docs.get_mut(key.document) else {
627 self.message = "snapshot written; source buffer closed".into();
628 self.finish_save_feedback(key.document);
629 self.collection_save_progress(key.document, false);
630 return;
631 };
632 let previous_path = document.buf.path.clone();
633 let saved = document.buf.accept_save(receipt);
634 let renamed = previous_path != document.buf.path;
635 if renamed {
636 self.lsp_close_document(key.document);
637 if !self.docs.is_empty() && self.current() == key.document {
638 self.lsp_maybe_attach();
639 }
640 }
641 self.message = if saved {
642 "written"
643 } else {
644 "snapshot written; newer edits remain unsaved"
645 }
646 .into();
647 self.request_session_save();
648 self.collection_save_progress(key.document, saved);
649 if saved
650 && key.close
651 && !self.docs.is_empty()
652 && self.current() == key.document
653 && self.focus_epoch == key.focus
654 {
655 self.close_pane_or_buffer(false);
656 }
657 }
658 Outcome::Failed { failure, .. } => {
659 self.collection_save_progress(key.document, false);
660 self.message = format!("write failed: {}", failure.message)
661 }
662 Outcome::Cancelled(_) => {
663 self.collection_save_progress(key.document, false);
664 self.message = "write cancelled".into();
665 }
666 }
667 self.finish_save_feedback(key.document);
668 }
669 IoEvent::Session { request, outcome } => {
670 if self.io.session != Some(request) {
671 return;
672 }
673 self.io.session = None;
674 self.worker_handles.remove(&request);
675 if let Outcome::Failed { failure, .. } = outcome {
676 self.message = format!("session save failed: {}", failure.message);
677 self.io.session_error = Some(self.message.clone());
678 }
679 if let Some(work) = self.io.queued_session.take() {
680 self.start_session_save(work);
681 }
682 }
683 }
684 }
685
686 pub fn io_pending(&self) -> bool {
687 !self.io.open.is_empty()
688 || self.review.preparing.is_some()
689 || !self.io.saves.is_empty()
690 || self.io.session.is_some()
691 || !self.io.native.is_empty()
692 || self.remote_work_pending()
693 }
694}
695
696impl Editor {
697 pub(crate) fn io_write_pending(&self, request: WorkerId) -> bool {
698 self.io.session == Some(request)
699 || self.remote_write_pending(request)
700 || self.destination_write_pending(request)
701 || self
702 .io
703 .saves
704 .values()
705 .any(|ticket| ticket.request == request)
706 || self.io.native.get(&request).is_some_and(|key| {
707 matches!(
708 key.operation,
709 native::Operation::Trust { .. } | native::Operation::TrustRemote { .. }
710 )
711 })
712 }
713 pub fn io_status(&self) -> Option<&'static str> {
714 if let Some(status) = self.remote_write_status() {
715 return Some(status);
716 }
717 if !self.io.saves.is_empty() {
718 Some("saving")
719 } else if !self.io.open.is_empty() {
720 Some("loading")
721 } else {
722 None
723 }
724 }
725
726 pub(crate) fn remote_refresh_pending(&self, document: DocumentId) -> bool {
727 self.io
728 .open
729 .values()
730 .any(|key| key.origin == document && matches!(key.intent, OpenIntent::Refresh))
731 }
732}
733
734impl IoState {
735 #[cfg(test)]
738 pub(crate) fn native_tickets(&self) -> Vec<Ticket<native::NativeKey>> {
739 self.native
740 .iter()
741 .map(|(request, key)| Ticket {
742 request: *request,
743 key: key.clone(),
744 })
745 .collect()
746 }
747}