Replace the oversized header and reference rail with stable editor slots and a retained supporting drawer. Bind diagnostic, result and menu actions to their original publication and visit; preserve effective selections on navigation and dismiss Actions, Find and the drawer one surface per Escape. Retain exact Windows source captures, eight final gates (871 desktop passes), independent reviews, native selection/rename/recovery trials and the original failures. All 526 tested inputs and staged evidence were byte-checked before commit. Combined preview integration and packaging remain with issue 27; Linux remains paused.
585 lines
25 KiB
Rust
585 lines
25 KiB
Rust
//! 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<String>) {
|
|
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<Id>,
|
|
) -> Task<Message> {
|
|
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<Message> {
|
|
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<Message> {
|
|
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<Message> {
|
|
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<Message> {
|
|
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();
|
|
}
|
|
if self.path.is_some()
|
|
|| self.hosted.session.as_ref().is_none_or(|session| {
|
|
session.resource_id != remote.resource_id
|
|
|| session.connection.base() != request.connection.base()
|
|
})
|
|
{
|
|
self.reset_source_navigation_session();
|
|
}
|
|
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<Message> {
|
|
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());
|
|
if self.path.is_some()
|
|
|| self.hosted.session.as_ref().is_none_or(|session| {
|
|
session.resource_id != remote.resource_id
|
|
|| session.connection.base() != connection.base()
|
|
})
|
|
{
|
|
self.reset_source_navigation_session();
|
|
}
|
|
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)
|
|
// A changed bound head can enter/leave the active
|
|
// filter or page even when no row was displayed.
|
|
|| 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.uncertain.as_ref().is_some_and(|failed| {
|
|
failed.operation == Operation::Save
|
|
&& failed.connection.base() == request.connection.base()
|
|
&& failed.resource_id == Some(remote.resource_id)
|
|
&& failed.etag.as_ref().is_some_and(|etag| etag != &remote.etag)
|
|
})
|
|
|| 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;
|