//! Desktop destination lifecycle; HTTP never becomes a second editing history. use super::*; use crate::hosted::{ Event, Operation, Outcome, Request, Review, Session, client::{Connection, Failure, Remote}, }; impl App { pub fn hosted_label(&self) -> String { self.hosted.session.as_ref().map_or_else( || { if self.dirty() { "Unsaved changes".into() } else if self.path.is_some() { "All changes saved".into() } else { "Local project".into() } }, |session| { format!( "Hosted · revision {} · {}", session.revision, if self.dirty() { "local changes" } else { "saved" } ) }, ) } pub fn save_label(&self) -> &'static str { if self.hosted.session.is_some() { "Save to server" } else { "Save all" } } fn hosted_problem(&mut self, detail: impl Into) { let detail = detail.into(); self.status = detail.clone(); self.hosted.error = Some(Failure { detail, code: "desktop_hosted".into(), status: None, uncertain: false, published: None, }); } fn hosted_ready(&mut self) -> bool { if self.pending_recovery.is_some() { self.continue_after_save = false; self.pending = None; self.hosted.visible = false; self.page = Page::Text; self.status = "Recover the interrupted source save before changing storage destinations.".into(); return false; } if !self.apply_behavior() || !self.apply_source() || !self.apply() { self.hosted.visible = false; self.continue_after_save = false; self.pending = None; return false; } if self.source_workspace.detached_dirty() { self.hosted.visible = false; self.page = Page::Text; self.status = "Reattach or revert detached source drafts before saving to a server.".into(); self.continue_after_save = false; self.pending = None; return false; } true } fn hosted_start( &mut self, operation: Operation, connection: Connection, resource_id: Option, ) -> Task { let request = Request { id: Id::new_v4(), document: self.canvas_document, revision: self.document.revision(), operation, connection, resource_id, etag: self.hosted.session.as_ref().map(|s| s.etag.clone()), snapshot: self.document.snapshot(), }; self.hosted.request = Some(request.clone()); if operation != Operation::Check { self.hosted.error = None; } self.busy = true; self.status = match operation { Operation::Catalog(_) => "Reading hosted models…", Operation::Open(_) => "Opening hosted model…", Operation::Publish => "Publishing source project…", Operation::Save => "Saving to server…", Operation::Check => "Checking current server revision…", } .into(); let query = self.hosted.query.clone(); let after = if operation == Operation::Catalog(true) { self.hosted.next } else { None }; Task::perform( async move { let result = match operation { Operation::Catalog(_) => request .connection .catalog(&query, after) .await .map(Outcome::Catalog), Operation::Open(id) => request.connection.open(id).await.map(Outcome::Remote), Operation::Publish => request .connection .publish(request.snapshot.bundle.clone()) .await .map(Outcome::Remote), Operation::Save => request .connection .save( request.resource_id.expect("save has a resource"), request.etag.as_deref().expect("save has a precondition"), request.snapshot.bundle.clone(), ) .await .map(Outcome::Remote), Operation::Check => request .connection .open(request.resource_id.expect("check has a resource")) .await .map(Outcome::Remote), }; Message::Hosted(Event::Completed(request.id, request.document, result)) }, |message| message, ) } pub(super) fn open_hosted(&mut self) -> Task { if self.pending_recovery.is_some() { self.hosted_problem( "Recover the interrupted source save before opening another model.", ); return Task::none(); } if let Some((connection, id)) = self.hosted.open_target.take() { return self.hosted_start(Operation::Open(id), connection, Some(id)); } Task::none() } pub(super) fn save_hosted(&mut self) -> Task { if !self.hosted_ready() { return Task::none(); } if self.hosted.review.is_some() { self.hosted.visible = true; self.hosted_problem("The server has a different version. Review it before saving; your local work is retained."); self.continue_after_save = false; self.pending = None; return Task::none(); } if let Some(session) = &self.hosted.session { return self.hosted_start( Operation::Save, session.connection.clone(), Some(session.resource_id), ); } Task::none() } fn check_hosted(&mut self) -> Task { let destination = self .hosted .uncertain .as_ref() .and_then(|r| r.resource_id.map(|id| (r.connection.clone(), id))) .or_else(|| { self.hosted .session .as_ref() .map(|s| (s.connection.clone(), s.resource_id)) }); if let Some((connection, id)) = destination { return self.hosted_start(Operation::Check, connection, Some(id)); } self.hosted_problem("The publication result is unknown. Refresh the catalog and inspect the hosted models before choosing another action."); Task::none() } fn acknowledge_hosted(&mut self, request: &Request, remote: Remote) -> Task { if remote.compiled.bundle != request.snapshot.bundle { self.hosted.review = Some(Review { connection: request.connection.clone(), remote, }); self.hosted.review_path = None; self.hosted.visible = true; self.hosted_problem("The server returned a different source version. Review it; the local history has been preserved."); self.continue_after_save = false; self.pending = None; return Task::none(); } self.hosted.session = Some(Session { connection: request.connection.clone(), resource_id: remote.resource_id, revision: remote.revision, etag: remote.etag, }); self.path = None; self.disk_project = None; self.document.mark_saved(request.snapshot.clone()); self.hosted.uncertain = None; self.hosted.review = None; self.hosted.review_path = None; self.hosted.error = None; self.status = format!( "Hosted revision {} saved. Undo remains local until your next Save.", remote.revision ); // A late receipt may save an older snapshot; it must not close newer work. if self.continue_after_save { self.continue_after_save = false; if request.revision == self.document.revision() && !self.dirty() && let Some(pending) = self.pending.take() { return self.proceed(pending); } self.pending = None; } Task::none() } pub(super) fn hosted_event(&mut self, event: Event) -> Task { match event { Event::Show => { self.hosted.visible = true; self.model_menu = None; self.canvas_epoch = self.canvas_epoch.wrapping_add(1); } Event::Close => self.hosted.visible = false, Event::Base(base) => { self.hosted.base = base; self.hosted.connection = None; self.hosted.catalog.clear(); self.hosted.invalidate_catalog(); self.hosted.selected = None; } Event::Token(token) => { self.hosted.token = token; self.hosted.connection = None; self.hosted.catalog.clear(); self.hosted.selected = None; self.hosted.invalidate_catalog(); } Event::Query(query) => { if self.hosted.query != query { self.hosted.query = query; self.hosted.selected = None; self.hosted.invalidate_catalog(); } } Event::Connect => match Connection::new(&self.hosted.base, self.hosted.token.clone()) { Ok(connection) => { self.hosted.invalidate_catalog(); return self.hosted_start(Operation::Catalog(false), connection, None); } Err(error) => { self.status = error.detail.clone(); self.hosted.error = Some(error); } }, Event::More => { if !self.hosted.catalog_stale && self.hosted.next.is_some() && let Some(connection) = &self.hosted.connection { return self.hosted_start(Operation::Catalog(true), connection.clone(), None); } } Event::Select(id) => self.hosted.selected = Some(id), Event::OpenSelected => { if let (Some(connection), Some(id)) = (&self.hosted.connection, self.hosted.selected) { self.hosted.open_target = Some((connection.clone(), id)); self.hosted.visible = false; return self.handle(Message::Request(Pending::HostedOpen)); } } Event::Publish => { if !self.hosted_ready() { return Task::none(); } let connection = match Connection::new(&self.hosted.base, self.hosted.token.clone()) { Ok(connection) => connection, Err(error) => { self.status = error.detail.clone(); self.hosted.error = Some(error); return Task::none(); } }; return self.hosted_start(Operation::Publish, connection, None); } Event::Save => return self.save_hosted(), Event::Check => return self.check_hosted(), Event::ReviewFile(path) => { if let Some(source) = self .hosted .review .as_ref() .and_then(|remote| remote.remote.compiled.bundle.files.get(&path)) { self.hosted.review_editor = text_editor::Content::with_text(source); self.hosted.review_path = Some(path); } } Event::ReviewAction(action) => { if !action.is_edit() { self.hosted.review_editor.perform(action); } } Event::ReviewBack => self.hosted.review_path = None, Event::Reload => { if !self.hosted_ready() { return Task::none(); } if let Some(review) = self.hosted.review.clone() { let remote = review.remote; let connection = review.connection; { match self.document.apply_compiled((*remote.compiled).clone()) { Ok(_) => { self.document.mark_saved(remote.compiled.clone()); self.hosted.session = Some(Session { connection, resource_id: remote.resource_id, revision: remote.revision, etag: remote.etag, }); self.path = None; self.disk_project = None; self.hosted.review = None; self.hosted.review_path = None; self.hosted.uncertain = None; self.hosted.error = None; self.sync(); self.hosted.visible = false; self.status = "Server version loaded. Undo restores the previous accepted local version.".into(); } Err(error) => self.hosted_problem(error.to_string()), } } } } Event::Local => { self.hosted.visible = false; return self.handle(Message::Save(true)); } Event::Forget => { if self.hosted.session.is_some() { self.hosted_problem("Save as local before disconnecting this hosted model."); } else { self.hosted = Default::default(); self.hosted.visible = true; } } Event::Completed(id, document, result) => { let Some(request) = self.hosted.request.as_ref() else { return Task::none(); }; if request.id != id || request.document != document || document != self.canvas_document { return Task::none(); } let request = self.hosted.request.take().expect("matching request"); self.busy = false; // A catalog is a read snapshot, never a live mirror. Known or // uncertain writes and observed newer revisions invalidate only // the service whose results are currently displayed. let catalog_changed = match &result { Ok(Outcome::Remote(remote)) => { matches!(request.operation, Operation::Publish | Operation::Save) || self.hosted.catalog.iter().any(|item| { item.resource_id == remote.resource_id && item.revision != remote.revision }) } Err(error) => { matches!(request.operation, Operation::Publish | Operation::Save) && (error.uncertain || error.status == Some(412)) } Ok(Outcome::Catalog(_)) => false, }; if catalog_changed && self .hosted .connection .as_ref() .is_some_and(|connection| connection.base() == request.connection.base()) { self.hosted.invalidate_catalog(); } match result { Err(error) => { let conflict = error.status == Some(412) && request.operation == Operation::Save; if error.uncertain || conflict { let mut failed = request.clone(); if let Some(id) = error.published { failed.resource_id = Some(id); } if matches!(request.operation, Operation::Save | Operation::Publish) { self.hosted.uncertain = Some(failed); } } self.status = error.detail.clone(); self.hosted.error = Some(error); self.hosted.visible = true; self.continue_after_save = false; self.pending = None; if conflict { return self.check_hosted(); } } Ok(Outcome::Catalog(catalog)) => { if let Some(session) = &mut self.hosted.session && session.connection.base() == request.connection.base() { session.connection = request.connection.clone(); } if let Some(failed) = &mut self.hosted.uncertain && failed.connection.base() == request.connection.base() { failed.connection = request.connection.clone(); } if let Some(review) = &mut self.hosted.review && review.connection.base() == request.connection.base() { review.connection = request.connection.clone(); } self.hosted.connection = Some(request.connection); if request.operation != Operation::Catalog(true) { self.hosted.catalog.clear(); } for item in catalog.items { if !self .hosted .catalog .iter() .any(|old| old.resource_id == item.resource_id) { self.hosted.catalog.push(item); } } self.hosted.next = catalog.next_after; self.hosted.catalog_stale = false; if !self .hosted .catalog .iter() .any(|item| Some(item.resource_id) == self.hosted.selected) { self.hosted.selected = None; } self.status = format!("{} hosted models shown", self.hosted.catalog.len()); } Ok(Outcome::Remote(remote)) => match request.operation { Operation::Open(_) => { match Document::from_bundle(remote.compiled.bundle.clone(), LIMITS) { Ok(document) => { self.reset(document.project().clone(), None); self.document = document; self.source_workspace = Workspace::new(self.document.bundle(), true); self.sync(); self.hosted.session = Some(Session { connection: request.connection, resource_id: remote.resource_id, revision: remote.revision, etag: remote.etag, }); self.status = "Hosted source project opened. Edits and Undo stay local until Save to server.".into(); } Err(error) => self.hosted_problem(error.to_string()), } } Operation::Publish | Operation::Save => { return self.acknowledge_hosted(&request, remote); } Operation::Check => { let acknowledged = self .hosted .uncertain .as_ref() .filter(|failed| { failed.connection.base() == request.connection.base() && failed.resource_id == Some(remote.resource_id) && failed.snapshot.bundle == remote.compiled.bundle }) .cloned(); if let Some(failed) = acknowledged { return self.acknowledge_hosted(&failed, remote); } if self.hosted.session.as_ref().is_some_and(|session| { session.connection.base() == request.connection.base() && session.resource_id == remote.resource_id && session.etag == remote.etag }) { self.hosted.review = None; self.hosted.review_path = None; self.status = "The server revision is unchanged. Local edits are retained." .into(); } else { self.status = format!( "Server revision {} is available. Review before replacing local work.", remote.revision ); self.hosted.review = Some(Review { connection: request.connection.clone(), remote, }); self.hosted.review_path = None; self.hosted.visible = true; } } Operation::Catalog(_) => { self.hosted_problem("Unexpected response to catalog request.") } }, } } } Task::none() } } #[cfg(test)] mod tests;