mod codec;
pub(super) mod native;
mod navigation;
mod open;
#[cfg(test)]
mod remote_tests;
mod save;
use super::{Document, Editor};
use crate::files::FileTarget;
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::mpsc::{self, Receiver, Sender};
use strop_core::id::{BufferRevision, ByteColumn, DocumentId, LineIndex};
use strop_core::worker::{self, Completion, FailureKind, Outcome, Ticket, WorkerId};
use strop_core::SaveReceipt;
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum OpenIntent {
Switch {
readonly: bool,
},
Split {
vertical: bool,
},
AtLine {
line: LineIndex,
},
Refresh,
Browse,
DirectoryParent {
child: strop_workspace::ResourceLocation,
},
RemoteDestination,
RemoteView {
view: super::remote::RemoteView,
line: Option<LineIndex>,
},
Grep {
line: LineIndex,
column: ByteColumn,
},
SearchHit(super::picker::ReplacementHit),
LspLocation {
context: strop_lsp::ReplyContext,
position: strop_lsp::ServerPosition,
},
CollectionSource {
owner: WorkerId,
},
}
impl OpenIntent {
fn requires_file(&self) -> bool {
match self {
Self::AtLine { .. }
| Self::Grep { .. }
| Self::SearchHit(_)
| Self::LspLocation { .. } => true,
Self::RemoteView { view, line } => {
line.is_some() || *view != super::remote::RemoteView::default()
}
_ => false,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct OpenKey {
pub path: FileTarget,
pub origin: DocumentId,
pub revision: BufferRevision,
pub focus: u64,
pub intent: OpenIntent,
pub selection: strop_remote::ReadSelection,
}
pub struct Opened {
pub document: Document,
pub canonical: FileTarget,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct SaveKey {
pub document: DocumentId,
pub revision: BufferRevision,
pub focus: u64,
pub close: bool,
#[serde(with = "strop_core::path_serde::option")]
pub target: Option<PathBuf>,
pub force: bool,
}
#[derive(serde::Serialize, serde::Deserialize)]
pub enum IoEvent {
Open(Box<Completion<OpenKey, Opened>>),
Save(Box<Completion<SaveKey, SaveReceipt>>),
Native(Box<Completion<native::NativeKey, native::NativeResult>>),
Remote(super::remote::RemoteEvent),
DirectoryFilter(Box<Completion<super::directory::FilterKey, Opened>>),
Filesystem(Box<super::filesystem::FsEvent>),
Review(
Box<
Completion<
super::changes::review::prepare::PreparationKey,
super::changes::review::prepare::PreparedReview,
>,
>,
),
Session {
request: WorkerId,
outcome: Outcome<()>,
},
}
pub struct IoState {
pub tx: Sender<IoEvent>,
pub rx: Option<Receiver<IoEvent>>,
pub open: HashMap<WorkerId, OpenKey>,
navigation: Option<WorkerId>,
saves: HashMap<DocumentId, Ticket<SaveKey>>,
session: Option<WorkerId>,
queued_session: Option<crate::session::SaveRequest>,
native: HashMap<WorkerId, native::NativeKey>,
pub session_error: Option<String>,
pub(crate) format_warnings: HashMap<DocumentId, String>,
}
impl Default for IoState {
fn default() -> Self {
let (tx, rx) = mpsc::channel();
Self {
tx,
rx: Some(rx),
open: HashMap::new(),
navigation: None,
saves: HashMap::new(),
session: None,
queued_session: None,
native: HashMap::new(),
session_error: None,
format_warnings: HashMap::new(),
}
}
}
impl Editor {
pub fn request_open(&mut self, path: PathBuf, intent: OpenIntent) {
self.request_target(FileTarget::Local(path), intent);
}
pub fn request_target(&mut self, target: FileTarget, intent: OpenIntent) {
self.remember_directory_view();
if target
.resource_location()
.is_some_and(|location| self.filesystem.blocks(&location))
{
self.message = "filesystem operation pending or unconfirmed; verify before reopening this resource".into();
return;
}
if matches!(intent, OpenIntent::Refresh) && self.directory().is_some() {
if let Err(error) = self.start_directory_task(
self.current(),
super::directory::DirectoryTask::Reload,
None,
) {
self.message = error;
}
return;
}
if matches!(intent, OpenIntent::Refresh) && self.remote_write_blocks_refresh(self.current())
{
self.message =
"remote save pending or unconfirmed; settle or :remote verify before refresh"
.into();
return;
}
let selection = match &intent {
OpenIntent::RemoteView { view, .. } => view.selection(),
OpenIntent::Refresh => self
.cur()
.remote_metadata()
.map_or(strop_remote::ReadSelection::Full, |source| source.selection),
_ => strop_remote::ReadSelection::Full,
};
let requires_file = intent.requires_file();
let browse = matches!(
intent,
OpenIntent::Browse | OpenIntent::DirectoryParent { .. }
) || (matches!(intent, OpenIntent::Refresh) && self.directory().is_some());
if !matches!(target, FileTarget::Remote(_))
&& matches!(
intent,
OpenIntent::RemoteView { .. } | OpenIntent::RemoteDestination
)
{
self.message = "range/tail/follow views require a remote target".into();
return;
}
let path = match target {
FileTarget::Local(path) => FileTarget::Local(self.cwd.join(path)),
remote => remote,
};
self.cancel_open(worker::CancelReason::Superseded);
if let FileTarget::Container { container, path } = &path {
if !self.containers.attached.contains_key(container.as_str()) {
self.attach_container_target(container.clone(), path.clone(), intent);
return;
}
}
let existing = self.docs.iter().find_map(|(id, document)| {
(document.matches_target(&path)
&& document
.remote_metadata()
.is_none_or(|source| source.selection == selection))
.then_some(id)
});
if let Some(id) = existing.filter(|&id| {
!matches!(intent, OpenIntent::Refresh)
&& !matches!(path, FileTarget::Container { .. })
&& (!matches!(
intent,
OpenIntent::Browse
| OpenIntent::DirectoryParent { .. }
| OpenIntent::RemoteDestination
) || self.doc(id).directory_metadata_ref().is_none())
}) {
if (requires_file && self.doc(id).directory_metadata_ref().is_some())
|| (browse && self.doc(id).directory_metadata_ref().is_none())
{
self.message = if browse {
"browse requires a directory"
} else {
"this view requires a regular file"
}
.into();
return;
}
self.finish_open(id, intent);
return;
}
let request = match self.worker_ids.allocate() {
Ok(request) => request,
Err(error) => {
self.message = error.message;
return;
}
};
let key = OpenKey {
path: path.clone(),
origin: self.current(),
revision: self.buf().revision(),
focus: self.focus_epoch,
intent,
selection,
};
if !matches!(key.intent, OpenIntent::CollectionSource { .. }) {
self.io.navigation = Some(request);
}
self.io.open.insert(request, key.clone());
self.message = format!("loading {path}");
let tx = self.io.tx.clone();
let ticket = Ticket { request, key };
match self.tape.request("io.open", &ticket) {
Ok(false) => return,
Ok(true) => {}
Err(error) => {
self.handle_io(IoEvent::Open(Box::new(Completion {
ticket,
outcome: Outcome::failed(FailureKind::Protocol, error.to_string()),
})));
return;
}
}
let work = open::OpenRead {
container: match &path {
FileTarget::Container { container, .. } => {
self.containers.attached.get(container.as_str()).cloned()
}
_ => None,
},
previous_directories: {
let mut previous: Vec<_> = self
.docs
.iter()
.filter_map(|(_, doc)| doc.directory_metadata_ref().cloned())
.collect();
for saved in self.directories.views.values() {
if !previous
.iter()
.any(|source| source.location == saved.directory.location)
{
previous.push(saved.directory.clone());
}
}
previous
},
target: path,
browse,
requires_file,
selection,
client: self.remote_client(),
reveal: match &ticket.key.intent {
OpenIntent::DirectoryParent { child } => Some(child.clone()),
_ => None,
},
};
let handle = worker::spawn(
"strop-open",
move |outcome| {
let _ = tx.send(IoEvent::Open(Box::new(Completion { ticket, outcome })));
},
move |cancel| work.run(&cancel),
);
self.worker_handles.insert(request, handle);
}
fn open_fresh(&self, key: &OpenKey) -> bool {
if self.finishing {
return false;
}
if let OpenIntent::CollectionSource { owner } = key.intent {
return !self.docs.is_empty()
&& self
.collection_build
.as_ref()
.is_some_and(|build| build.owner == owner);
}
if let OpenIntent::LspLocation { context, .. } = &key.intent {
if !self.lsp_context_fresh(context) {
return false;
}
}
!self.docs.is_empty()
&& self.current() == key.origin
&& self.focus_epoch == key.focus
&& self.buf().revision() == key.revision
}
pub fn reresolve_indents(&mut self) {
let ids: Vec<_> = self.docs.iter().map(|(id, _)| id).collect();
for id in ids {
self.resolve_indent_for(id);
}
}
pub(crate) fn resolve_indent_for(&mut self, document: DocumentId) {
use super::document::{Detection, IndentSource};
let detection = if self.config.indent_detect {
self.docs.get(document).and_then(|doc| doc.detection)
} else {
None
};
let configured = super::document::Indent {
style: self.config.indent_style,
width: self.config.tab_size,
style_source: IndentSource::Configured,
width_source: IndentSource::Configured,
};
let Some(doc) = self.docs.get_mut(document) else {
return;
};
let (style, style_source) = match (doc.indent_override.style, detection) {
(Some(style), _) => (style, IndentSource::Manual),
(None, Some(Detection::Tabs { .. })) => {
(crate::config::IndentStyle::Tabs, IndentSource::Detected)
}
(None, Some(Detection::Spaces { .. })) => {
(crate::config::IndentStyle::Spaces, IndentSource::Detected)
}
(None, _) => (configured.style, IndentSource::Configured),
};
let (width, width_source) = match (doc.indent_override.width, detection, style_source) {
(Some(width), _, _) => (width, IndentSource::Manual),
(None, Some(Detection::Spaces { width, .. }), IndentSource::Detected) => {
(width, IndentSource::Detected)
}
(None, _, _) => (configured.width, IndentSource::Configured),
};
doc.indent = super::document::Indent {
style,
width,
style_source,
width_source,
};
}
fn finish_open(&mut self, document: DocumentId, intent: OpenIntent) {
self.resolve_indent_for(document);
match intent {
OpenIntent::SearchHit(hit) => {
let Some(range) =
super::picker::checked_hit_range(self.doc(document).buf.text(), &hit)
else {
self.message = format!(
"{}: search hit changed; refresh Search before opening it",
self.doc(document).label(&self.cwd)
);
return;
};
self.jump_land(document, range.start.get());
self.discover_git();
self.lsp_maybe_attach();
}
OpenIntent::LspLocation { context, position } => {
self.finish_lsp_jump(document, position, context)
}
OpenIntent::Split { vertical } => self.split_document(vertical, document),
OpenIntent::CollectionSource { owner } => self.collection_source_ready(owner),
intent => {
self.switch_to(document);
if self.doc(document).directory_metadata_ref().is_some()
&& self.filename_draft(document).is_none()
{
self.restore_directory_view(document);
} else if self.doc(document).directory_metadata_ref().is_none() {
self.set_head(0);
self.view_mut().view_top = 0;
}
match intent {
OpenIntent::Switch { readonly: true } => self.buf_mut().readonly = true,
OpenIntent::DirectoryParent { child } => {
if let Some(line) = self
.directory()
.and_then(|directory| directory.line_for(&child))
{
self.set_head(self.buf().line_start(line));
self.place_jump_target();
}
}
OpenIntent::AtLine { line } => {
self.set_head(
self.buf()
.line_start(line.get().min(self.buf().last_content_line())),
);
self.run_motion("^");
}
OpenIntent::RemoteView { view, line } => {
if let Some(line) = line {
self.set_head(
self.buf()
.line_start(line.get().min(self.buf().last_content_line())),
);
self.run_motion("^");
} else if view.follow_limit().is_some() {
self.set_head(super::document::last_position(self.buf().text()));
}
if let Some(limit) = view.follow_limit() {
self.start_remote_follow(document, limit);
}
}
OpenIntent::Grep { line, column } => {
let line = line.get().min(self.buf().last_content_line());
let offset = self
.buf()
.line_start(line)
.saturating_add(column.get())
.min(self.buf().line_end(line));
self.set_head(self.buf().clamp_boundary(offset));
self.place_jump_target();
}
_ => {}
}
self.remember_directory_view();
self.remember_remote_destination();
self.discover_git();
self.lsp_maybe_attach();
}
}
}
pub(crate) fn request_session_save(&mut self) {
let Some(work) = crate::session::capture_save(self) else {
return;
};
if self.io.session.is_some() {
self.io.queued_session = Some(work);
} else {
self.start_session_save(work);
}
}
fn start_session_save(&mut self, work: crate::session::SaveRequest) {
let request = match self.worker_ids.allocate() {
Ok(request) => request,
Err(error) => {
self.message = error.message;
return;
}
};
self.io.session = Some(request);
match self.tape.request("io.session", &request) {
Ok(false) => return,
Ok(true) => {}
Err(error) => {
self.handle_io(IoEvent::Session {
request,
outcome: Outcome::failed(FailureKind::Protocol, error.to_string()),
});
return;
}
}
let tx = self.io.tx.clone();
let handle = worker::spawn(
"strop-session",
move |outcome| {
let _ = tx.send(IoEvent::Session { request, outcome });
},
move |_| match work.persist() {
Ok(()) => Outcome::Success(()),
Err(error) => Outcome::failed(FailureKind::Io, error.to_string()),
},
);
self.worker_handles.insert(request, handle);
}
pub fn handle_io(&mut self, event: IoEvent) {
super::trace::services::io(&event);
match event {
IoEvent::Native(completion) => self.handle_native(*completion),
IoEvent::Remote(event) => self.handle_remote_event(event),
IoEvent::Review(completion) => self.handle_review_prepared(*completion),
IoEvent::DirectoryFilter(completion) => self.directory_filter_done(*completion),
IoEvent::Filesystem(event) => self.handle_filesystem(*event),
IoEvent::Open(completion) => {
let request = completion.ticket.request;
if self.io.open.get(&request) != Some(&completion.ticket.key) {
return;
}
let Some(key) = self.io.open.remove(&request) else {
return;
};
self.worker_handles.remove(&request);
if self.io.navigation == Some(request) {
self.io.navigation = None;
}
if !self.open_fresh(&key) {
return;
}
match completion.outcome {
Outcome::Success(mut opened) => {
if matches!(key.intent, OpenIntent::Refresh) {
self.revoke_remote_write(key.origin);
self.finish_refresh(key.origin, opened.document);
return;
}
if !self.cur().matches_target(&opened.canonical) {
opened.document.set_return_point(self.jump_record());
}
let existing = self.docs.iter().find_map(|(id, document)| {
(document.matches_target(&opened.canonical)
&& document
.remote_metadata()
.is_none_or(|source| source.selection == key.selection))
.then_some(id)
});
let id = if let Some(id) = existing {
if self.doc(id).directory_metadata_ref().is_some()
|| (matches!(key.path, FileTarget::Container { .. })
&& !self.doc(id).buf.dirty)
{
if let Err(error) =
self.publish_source_snapshot(id, opened.document, false)
{
self.message = error.to_string();
return;
}
}
id
} else {
if let OpenIntent::SearchHit(hit) = &key.intent {
if super::picker::checked_hit_range(opened.document.buf.text(), hit)
.is_none()
{
self.message = format!(
"{}: search hit changed; refresh Search before opening it",
key.path
);
return;
}
}
let takes_focus =
!matches!(key.intent, OpenIntent::CollectionSource { .. });
let endpoint = opened
.document
.remote_metadata()
.map(|source| source.file.endpoint().clone())
.or_else(|| {
opened
.document
.directory_metadata_ref()
.and_then(|directory| {
directory.location.filesystem.endpoint().cloned()
})
});
if let Some(endpoint) = endpoint {
self.workspaces
.bind(strop_workspace::Filesystem::Remote(endpoint), None);
}
let id = self.docs.insert(opened.document);
if takes_focus {
self.drop_stale_scratch(id);
}
self.generation += 1;
self.mru.push(id);
id
};
self.message.clear();
self.finish_open(id, key.intent);
}
Outcome::Failed { failure, .. } => {
if let OpenIntent::CollectionSource { owner } = key.intent {
self.collection_source_ready(owner);
}
if let Some(source) = self
.docs
.get_mut(key.origin)
.and_then(|doc| doc.directory_metadata_mut())
{
source.stale = Some(failure.message.clone());
}
self.message = format!("open {}: {}", key.path, failure.message);
}
Outcome::Cancelled(_) => {}
}
}
IoEvent::Save(completion) => {
let request = completion.ticket.request;
if self.io.saves.get(&completion.ticket.key.document) != Some(&completion.ticket) {
return;
}
let key = completion.ticket.key;
self.io.saves.remove(&key.document);
self.worker_handles.remove(&request);
match completion.outcome {
Outcome::Success(receipt) => {
let Some(document) = self.docs.get_mut(key.document) else {
self.message = "snapshot written; source buffer closed".into();
self.finish_save_feedback(key.document);
self.collection_save_progress(key.document, false);
return;
};
let previous_path = document.buf.path.clone();
let saved = document.buf.accept_save(receipt);
let renamed = previous_path != document.buf.path;
if renamed {
self.lsp_close_document(key.document);
if !self.docs.is_empty() && self.current() == key.document {
self.lsp_maybe_attach();
}
}
self.message = if saved {
"written"
} else {
"snapshot written; newer edits remain unsaved"
}
.into();
self.request_session_save();
self.collection_save_progress(key.document, saved);
if saved
&& key.close
&& !self.docs.is_empty()
&& self.current() == key.document
&& self.focus_epoch == key.focus
{
self.close_pane_or_buffer(false);
}
}
Outcome::Failed { failure, .. } => {
self.collection_save_progress(key.document, false);
self.message = format!("write failed: {}", failure.message)
}
Outcome::Cancelled(_) => {
self.collection_save_progress(key.document, false);
self.message = "write cancelled".into();
}
}
self.finish_save_feedback(key.document);
}
IoEvent::Session { request, outcome } => {
if self.io.session != Some(request) {
return;
}
self.io.session = None;
self.worker_handles.remove(&request);
if let Outcome::Failed { failure, .. } = outcome {
self.message = format!("session save failed: {}", failure.message);
self.io.session_error = Some(self.message.clone());
}
if let Some(work) = self.io.queued_session.take() {
self.start_session_save(work);
}
}
}
}
pub fn io_pending(&self) -> bool {
!self.io.open.is_empty()
|| self.review.preparing.is_some()
|| !self.directories.filters.is_empty()
|| self.filesystem.pending()
|| !self.io.saves.is_empty()
|| self.io.session.is_some()
|| !self.io.native.is_empty()
|| self.remote_work_pending()
}
}
impl Editor {
pub(crate) fn io_write_pending(&self, request: WorkerId) -> bool {
self.io.session == Some(request)
|| self.remote_write_pending(request)
|| self.destination_write_pending(request)
|| self.filesystem.mutation_pending(request)
|| self
.io
.saves
.values()
.any(|ticket| ticket.request == request)
|| self.io.native.get(&request).is_some_and(|key| {
matches!(
key.operation,
native::Operation::Trust { .. } | native::Operation::TrustRemote { .. }
)
})
}
pub fn io_status(&self) -> Option<&'static str> {
if let Some(status) = self.remote_write_status() {
return Some(status);
}
if !self.io.saves.is_empty() {
Some("saving")
} else if !self.io.open.is_empty() {
Some("loading")
} else {
None
}
}
pub(crate) fn remote_refresh_pending(&self, document: DocumentId) -> bool {
self.io
.open
.values()
.any(|key| key.origin == document && matches!(key.intent, OpenIntent::Refresh))
}
}
impl IoState {
pub(crate) fn save_pending_for(&self, document: DocumentId) -> bool {
self.saves.contains_key(&document)
}
#[cfg(test)]
pub(crate) fn native_tickets(&self) -> Vec<Ticket<native::NativeKey>> {
self.native
.iter()
.map(|(request, key)| Ticket {
request: *request,
key: key.clone(),
})
.collect()
}
}