From 4c5f200b3d12003625c14aa5ddc4bb5889f43b41 Mon Sep 17 00:00:00 2001 From: Kayshen-X Date: Sat, 1 Aug 2026 12:15:30 +0800 Subject: [PATCH] feat(editor): surface collab conflict details and replay discarded edits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A guest edit that loses a collaboration conflict is no longer silently dropped behind a generic toast. The cancellation now carries the dropped EditChanges end-to-end: the conflict notice names the discarded nodes and fields (dedicated EditConflictDiscarded notice kind so the detail can never attach to an unrelated conflict toast), and the dropped property intent is stashed so the collab panel can resubmit it on request through the new op_collab::reapply_property_changes API. The stash is created only for genuine concurrency losses (property conflict / precondition failure) — policy, permission, and size rejections map to their own notices — and is cleared on every session-Ended path so it cannot outlive its session. Addresses one blocker (replay consumed the stash before acquiring the edit lane), four concerns, and one nit from external review. Verified by op-host-desktop collab (136), op-editor-core (19 collab), op-editor-ui (52 collab), op-collab, and op-i18n suites. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- crates/op-collab/src/diff_types.rs | 4 +- crates/op-collab/src/guest.rs | 7 +- crates/op-collab/src/guest_pending.rs | 18 +- crates/op-collab/src/guest_sync.rs | 2 + crates/op-collab/src/guest_types.rs | 14 +- crates/op-collab/src/lib.rs | 6 +- crates/op-collab/src/reapply.rs | 73 ++++++++ crates/op-collab/tests/guest.rs | 58 ++++++- .../op-editor-core/src/collab_panel_hover.rs | 1 + crates/op-editor-core/src/collab_ui_debug.rs | 1 + crates/op-editor-core/src/collab_ui_state.rs | 69 ++++++++ crates/op-editor-core/src/lib.rs | 11 +- .../src/widgets/collab_panel_interaction.rs | 1 + crates/op-editor-ui/src/widgets/collab_ui.rs | 33 +++- .../src/widgets/collab_ui_action.rs | 1 + .../src/widgets/collab_ui_debug.rs | 1 + .../src/widgets/collab_ui_tests.rs | 59 +++++++ crates/op-host-desktop/src/collab_runtime.rs | 44 +++++ .../src/collab_runtime/effects.rs | 164 +++++++++++++++++- .../src/collab_runtime/tests.rs | 120 ++++++++++++- .../src/i18n/catalog_integrity_tests.rs | 4 +- crates/op-i18n/src/i18n/de_collab.rs | 2 + crates/op-i18n/src/i18n/en_collab.rs | 2 + crates/op-i18n/src/i18n/es_collab.rs | 2 + crates/op-i18n/src/i18n/fr_collab.rs | 2 + crates/op-i18n/src/i18n/hi_collab.rs | 2 + crates/op-i18n/src/i18n/id_collab.rs | 2 + crates/op-i18n/src/i18n/ja_collab.rs | 2 + crates/op-i18n/src/i18n/ko_collab.rs | 2 + crates/op-i18n/src/i18n/pt_collab.rs | 2 + crates/op-i18n/src/i18n/ru_collab.rs | 2 + crates/op-i18n/src/i18n/th_collab.rs | 2 + crates/op-i18n/src/i18n/tr_collab.rs | 2 + crates/op-i18n/src/i18n/vi_collab.rs | 2 + crates/op-i18n/src/i18n/zh_cn_collab.rs | 2 + crates/op-i18n/src/i18n/zh_tw_collab.rs | 2 + 36 files changed, 682 insertions(+), 39 deletions(-) create mode 100644 crates/op-collab/src/reapply.rs diff --git a/crates/op-collab/src/diff_types.rs b/crates/op-collab/src/diff_types.rs index 207e325ea..7e2920484 100644 --- a/crates/op-collab/src/diff_types.rs +++ b/crates/op-collab/src/diff_types.rs @@ -50,7 +50,9 @@ pub enum SupportedNodeField { } impl SupportedNodeField { - pub(crate) const fn wire_name(self) -> &'static str { + /// Canonical schema field name, also used as the display token in + /// conflict notices. + pub const fn wire_name(self) -> &'static str { match self { Self::Name => "name", Self::X => "x", diff --git a/crates/op-collab/src/guest.rs b/crates/op-collab/src/guest.rs index 8b634341f..5abdcdb6d 100644 --- a/crates/op-collab/src/guest.rs +++ b/crates/op-collab/src/guest.rs @@ -390,10 +390,11 @@ impl GuestSessionCore { through_seq: prepared.post.confirmed_seq, }))); } - if let Some((client_op_id, reason)) = prepared.post.pending_cancel { + if let Some(cancelled) = prepared.post.pending_cancel { effects.push(GuestEffect::PendingCancelled { - client_op_id, - reason, + client_op_id: cancelled.client_op_id, + reason: cancelled.reason, + changes: cancelled.changes, }); } effects.extend(self.continue_after_install()?); diff --git a/crates/op-collab/src/guest_pending.rs b/crates/op-collab/src/guest_pending.rs index ecd3607ca..420b5876d 100644 --- a/crates/op-collab/src/guest_pending.rs +++ b/crates/op-collab/src/guest_pending.rs @@ -28,7 +28,7 @@ pub(crate) enum PendingRebase { pub(crate) type CarriedPending = ( Arc, Option, - Option<(crate::ClientOpId, PendingCancelReason)>, + Option, ); impl GuestSessionCore { @@ -199,10 +199,11 @@ impl GuestSessionCore { Ok(vec![GuestEffect::PendingCancelled { client_op_id: pending.client_op_id, reason: PendingCancelReason::AlreadySatisfied, + changes: pending.changes, }]) } PendingRebase::Conflict(reason) => { - self.stage_pending_rollback(pending.client_op_id, reason) + self.stage_pending_rollback(pending.client_op_id, reason, pending.changes) } } } @@ -211,6 +212,7 @@ impl GuestSessionCore { &mut self, client_op_id: crate::ClientOpId, reason: PendingCancelReason, + changes: EditChanges, ) -> Result, GuestError> { let confirmed = self .confirmed_document @@ -231,7 +233,11 @@ impl GuestSessionCore { pending: None, state: self.state, discard_buffer_through: None, - pending_cancel: Some((client_op_id, reason)), + pending_cancel: Some(crate::CancelledPendingEdit { + client_op_id, + reason, + changes, + }), undo_index_add: None, }, )?; @@ -383,7 +389,11 @@ pub(crate) fn carry_pending_over_confirmed( PendingRebase::Conflict(reason) => Ok(( confirmed.clone(), None, - Some((pending.client_op_id.clone(), reason)), + Some(crate::CancelledPendingEdit { + client_op_id: pending.client_op_id.clone(), + reason, + changes: pending.changes.clone(), + }), )), } } diff --git a/crates/op-collab/src/guest_sync.rs b/crates/op-collab/src/guest_sync.rs index 7a625d3fa..82645e786 100644 --- a/crates/op-collab/src/guest_sync.rs +++ b/crates/op-collab/src/guest_sync.rs @@ -399,6 +399,7 @@ impl GuestSessionCore { self.stage_pending_rollback( pending.client_op_id, PendingCancelReason::Rejected(reject.code), + pending.changes, ) } } @@ -445,6 +446,7 @@ impl GuestSessionCore { return self.stage_pending_rollback( pending.client_op_id, PendingCancelReason::Rejected(reject.code), + pending.changes, ); } diff --git a/crates/op-collab/src/guest_types.rs b/crates/op-collab/src/guest_types.rs index dd4cb347e..e4a58e316 100644 --- a/crates/op-collab/src/guest_types.rs +++ b/crates/op-collab/src/guest_types.rs @@ -64,6 +64,16 @@ pub enum PendingCancelReason { AlreadySatisfied, } +/// One optimistic local edit that lost to authoritative history and was +/// rolled back. `changes` preserves the dropped local intent so hosts can +/// stash it for a user-driven replay instead of losing the edit silently. +#[derive(Debug, Clone, PartialEq)] +pub struct CancelledPendingEdit { + pub client_op_id: ClientOpId, + pub reason: PendingCancelReason, + pub changes: EditChanges, +} + #[derive(Clone)] pub struct PendingEdit { pub(crate) client_op_id: ClientOpId, @@ -135,7 +145,7 @@ pub(crate) struct GuestPostInstall { pub pending: Option, pub state: GuestConnectionState, pub discard_buffer_through: Option, - pub pending_cancel: Option<(ClientOpId, PendingCancelReason)>, + pub pending_cancel: Option, pub undo_index_add: Option, } @@ -254,6 +264,8 @@ pub enum GuestEffect { PendingCancelled { client_op_id: ClientOpId, reason: PendingCancelReason, + /// The dropped local intent, for host-side stash and replay. + changes: EditChanges, }, /// Verify an owner renewal against the existing Noise-bound transport /// identity before replacing that connection's expiry deadline. diff --git a/crates/op-collab/src/lib.rs b/crates/op-collab/src/lib.rs index 44bd3e9af..fb8981924 100644 --- a/crates/op-collab/src/lib.rs +++ b/crates/op-collab/src/lib.rs @@ -26,6 +26,7 @@ mod hash; mod id_high_water; mod profile_validation; mod protocol; +mod reapply; mod serde_context; mod session; mod session_roster; @@ -60,8 +61,8 @@ pub use frame_direction::{ }; pub use guest::GuestSessionCore; pub use guest_types::{ - GuestConnectionState, GuestEffect, GuestError, GuestInstallReason, GuestSessionConfig, - PendingCancelReason, PendingEdit, PendingEditStatus, PreparedGuestInstall, + CancelledPendingEdit, GuestConnectionState, GuestEffect, GuestError, GuestInstallReason, + GuestSessionConfig, PendingCancelReason, PendingEdit, PendingEditStatus, PreparedGuestInstall, DEFAULT_GUEST_COMMIT_BUFFER_BYTES, DEFAULT_GUEST_COMMIT_BUFFER_ENTRIES, DEFAULT_GUEST_UNDO_INDEX_ENTRIES, MAX_GUEST_COMMIT_BUFFER_BYTES, MAX_GUEST_COMMIT_BUFFER_ENTRIES, MAX_GUEST_UNDO_INDEX_ENTRIES, @@ -84,6 +85,7 @@ pub use protocol::{ MAX_OPS_PER_TXN, MAX_PRESENCE_BYTES, MAX_PROCESSED_SUBTREE_NODES_PER_OP, MAX_TREE_DEPTH, MAX_TXN_BYTES, MAX_VALIDATION_NODE_VISITS_PER_TXN, }; +pub use reapply::{reapply_property_changes, ReapplyError}; pub use session::OwnerSessionCore; pub use session_types::{ AdmissionGrant, BoundUndoRequest, ConnectionKey, OwnerEffect, OwnerSessionConfig, diff --git a/crates/op-collab/src/reapply.rs b/crates/op-collab/src/reapply.rs new file mode 100644 index 000000000..ae20d2460 --- /dev/null +++ b/crates/op-collab/src/reapply.rs @@ -0,0 +1,73 @@ +//! Host-driven replay of a cancelled optimistic property edit. +//! +//! When a guest's pending edit loses to authoritative history the session +//! rolls the displayed document back and reports the dropped intent through +//! [`crate::GuestEffect::PendingCancelled`]. Hosts may stash those property +//! changes and, on an explicit user request, reassert them over the current +//! document with [`reapply_property_changes`] before submitting the result as +//! a brand-new local edit. + +use std::collections::BTreeMap; + +use jian_ops_schema::PenDocument; + +use crate::{ + apply_txn, canonical_node_hash, diff_fields::apply_changes_to_node, diff_index::TreeIndex, + ApplyContext, CanonicalHashError, CollabApplyError, CollabOp, CollabTxn, DiffError, + NodeFieldChange, +}; + +/// Failure to re-apply a previously discarded property edit. +#[derive(Debug, thiserror::Error)] +pub enum ReapplyError { + #[error("node `{node_id}` no longer exists in the current document")] + NodeMissing { node_id: String }, + #[error("discarded changes are not a supported property edit: {0}")] + Unsupported(#[from] DiffError), + #[error("replaying the discarded property edit failed: {0}")] + Apply(#[from] CollabApplyError), + #[error("hashing the replay target failed: {0}")] + Hash(#[from] CanonicalHashError), +} + +/// Re-apply the desired values of a discarded property edit onto `document`. +/// +/// Unlike the automatic rebase, this deliberately ignores each change's +/// `before` value: the caller is executing an explicit user request to +/// reassert the dropped intent over whatever the fields hold now. The target +/// node is looked up by id wherever it currently lives, so a page move does +/// not block the replay. Fails if any target node no longer exists. +pub fn reapply_property_changes( + document: &PenDocument, + changes: &[NodeFieldChange], +) -> Result { + let mut grouped: BTreeMap<&str, Vec> = BTreeMap::new(); + for change in changes { + grouped + .entry(change.node_id.as_str()) + .or_default() + .push(change.clone()); + } + let mut desired = document.clone(); + for (node_id, changes) in grouped { + let index = TreeIndex::build(&desired)?; + let entry = index + .entry(node_id) + .ok_or_else(|| ReapplyError::NodeMissing { + node_id: node_id.to_owned(), + })?; + let replacement = apply_changes_to_node(entry.node, &changes)?; + let operation = CollabOp::ReplaceExact { + page: entry.page.clone(), + node_id: node_id.to_owned(), + expected_hash: canonical_node_hash(entry.node)?, + node: replacement, + }; + desired = apply_txn( + &desired, + &CollabTxn::new(vec![operation]), + &ApplyContext::standalone_trusted(), + )?; + } + Ok(desired) +} diff --git a/crates/op-collab/tests/guest.rs b/crates/op-collab/tests/guest.rs index 7feaabc57..381a88528 100644 --- a/crates/op-collab/tests/guest.rs +++ b/crates/op-collab/tests/guest.rs @@ -1,11 +1,11 @@ use jian_ops_schema::{node::PenNode, PenDocument}; use op_collab::{ - apply_txn, canonical_document_hash, diff_supported, ApplyContext, ClientOpId, CollabMessage, - Commit, CommitAuthor, CommitSeq, DiffContext, Epoch, FrameEnvelope, GuestConnectionState, - GuestEffect, GuestError, GuestSessionConfig, GuestSessionCore, OpaqueTicket, Participant, - ParticipantId, PeerId, PeerNamespace, PendingCancelReason, PendingEditStatus, Reject, - RejectCode, RenewTicket, Role, SessionId, Snapshot, Submit, Welcome, WireLimits, - CANONICAL_HASH_VERSION, + apply_txn, canonical_document_hash, diff_supported, reapply_property_changes, ApplyContext, + ClientOpId, CollabMessage, Commit, CommitAuthor, CommitSeq, DiffContext, EditChanges, Epoch, + FrameEnvelope, GuestConnectionState, GuestEffect, GuestError, GuestSessionConfig, + GuestSessionCore, OpaqueTicket, Participant, ParticipantId, PeerId, PeerNamespace, + PendingCancelReason, PendingEditStatus, Reject, RejectCode, RenewTicket, Role, SessionId, + Snapshot, Submit, Welcome, WireLimits, CANONICAL_HASH_VERSION, }; fn initial_document() -> PenDocument { @@ -284,8 +284,13 @@ fn conflicting_remote_property_cancels_pending_after_atomic_install() { effect, GuestEffect::PendingCancelled { reason: PendingCancelReason::PropertyConflict { node_id, field }, + changes: EditChanges::Property(changes), .. - } if node_id == "base" && field == "x" + } if node_id == "base" + && field == "x" + && changes + .iter() + .any(|change| change.node_id == "base" && change.field.wire_name() == "x") ))); assert_eq!( canonical_document_hash(core.displayed_document().unwrap()).unwrap(), @@ -293,6 +298,45 @@ fn conflicting_remote_property_cancels_pending_after_atomic_install() { ); } +#[test] +fn cancelled_pending_changes_replay_over_the_new_authoritative_document() { + let initial = initial_document(); + let mut core = guest(Role::Editor); + install_initial(&mut core, &initial); + let local = set_position(&initial, 10.0, 0.0); + core.begin_local_edit(&local).unwrap(); + + let remote = set_position(&initial, 20.0, 0.0); + let prepared = take_install( + core.accept_frame(frame(CollabMessage::Commit(owner_commit( + 1, 1, &initial, &remote, + )))) + .unwrap(), + ); + let effects = finalize(&mut core, prepared); + let changes = effects + .iter() + .find_map(|effect| match effect { + GuestEffect::PendingCancelled { + changes: EditChanges::Property(changes), + .. + } => Some(changes.clone()), + _ => None, + }) + .expect("property conflict carries the dropped changes"); + + // Explicit user replay reasserts the dropped x=10 over the remote x=20. + let replayed = reapply_property_changes(core.displayed_document().unwrap(), &changes).unwrap(); + assert_eq!( + canonical_document_hash(&replayed).unwrap(), + canonical_document_hash(&set_position(&initial, 10.0, 0.0)).unwrap() + ); + + // A deleted target refuses the replay instead of resurrecting the node. + let empty: PenDocument = serde_json::from_str(r#"{"version":"1.0","children":[]}"#).unwrap(); + assert!(reapply_property_changes(&empty, &changes).is_err()); +} + #[test] fn out_of_order_commits_are_bounded_and_drained_in_sequence() { let initial = initial_document(); diff --git a/crates/op-editor-core/src/collab_panel_hover.rs b/crates/op-editor-core/src/collab_panel_hover.rs index cb39fc612..c4f60b64a 100644 --- a/crates/op-editor-core/src/collab_panel_hover.rs +++ b/crates/op-editor-core/src/collab_panel_hover.rs @@ -18,6 +18,7 @@ pub enum CollabPanelHover { Retry, Leave, DiscardPending, + ReapplyDiscarded, SaveAsFork, ApproveAdmissionEditor, ApproveAdmissionViewer, diff --git a/crates/op-editor-core/src/collab_ui_debug.rs b/crates/op-editor-core/src/collab_ui_debug.rs index cb716e10f..fbeb7af2d 100644 --- a/crates/op-editor-core/src/collab_ui_debug.rs +++ b/crates/op-editor-core/src/collab_ui_debug.rs @@ -32,6 +32,7 @@ impl fmt::Debug for CollabUiAction { Self::Retry => formatter.write_str("Retry"), Self::Leave => formatter.write_str("Leave"), Self::DiscardPending => formatter.write_str("DiscardPending"), + Self::ReapplyDiscarded => formatter.write_str("ReapplyDiscarded"), Self::SaveAsFork => formatter.write_str("SaveAsFork"), Self::ApproveAdmissionEditor { .. } => { formatter.write_str("ApproveAdmissionEditor([REDACTED])") diff --git a/crates/op-editor-core/src/collab_ui_state.rs b/crates/op-editor-core/src/collab_ui_state.rs index e0e645eed..702d2ce1f 100644 --- a/crates/op-editor-core/src/collab_ui_state.rs +++ b/crates/op-editor-core/src/collab_ui_state.rs @@ -311,6 +311,11 @@ pub enum CollabNoticeKind { OwnerLeft, EpochChanged, Reject(CollabRejectUiCode), + /// A pending local edit lost a concurrency conflict and was rolled back, + /// with the dropped intent stashed for replay. Distinct from + /// `Reject(Conflict)` so the notice detail (node/fields) is only ever + /// attached to the cancellation that produced the stash. + EditConflictDiscarded, UndoConflict, UnsupportedEdit(crate::collab_gate::CollabUnsupportedFeature), } @@ -330,6 +335,7 @@ impl CollabNoticeKind { Self::Reject(CollabRejectUiCode::ResourceLimit) => "collab.reject.resourceLimit", Self::Reject(CollabRejectUiCode::Authentication) => "collab.reject.authentication", Self::Reject(CollabRejectUiCode::Unknown) => "collab.reject.unknown", + Self::EditConflictDiscarded => "collab.reject.conflict", Self::UndoConflict => "collab.status.undoConflict", Self::UnsupportedEdit(_) => "collab.status.unsupportedEdit", } @@ -342,6 +348,63 @@ pub struct CollabNotice { pub created_at_ms: u64, } +/// Display bound for the conflict-detail node label. +pub const MAX_COLLAB_DISCARDED_NODE_LABEL_CHARS: usize = 40; +/// Display bound for the number of conflicting field names shown. +pub const MAX_COLLAB_DISCARDED_FIELDS: usize = 8; + +/// Display projection of one local edit that lost a collaboration conflict +/// and was rolled back. The raw replayable changes stay in the host runtime; +/// this carries only what the notice and the Reapply action need to paint. +#[derive(Clone, PartialEq, Eq)] +pub struct CollabDiscardedEditUi { + pub node_label: String, + /// Wire names of the dropped fields, deduplicated and bounded. + pub fields: Vec, +} + +impl std::fmt::Debug for CollabDiscardedEditUi { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + // The node label is authored document content; keep it out of debug + // dumps like the rest of the collaboration display projection. Field + // names are fixed schema tokens and stay visible for diagnosis. + formatter + .debug_struct("CollabDiscardedEditUi") + .field("node_label", &"[REDACTED]") + .field("fields", &self.fields) + .finish() + } +} + +impl CollabDiscardedEditUi { + pub fn bounded( + node_label: impl Into, + fields: impl IntoIterator, + ) -> Self { + let node_label: String = node_label + .into() + .trim() + .chars() + .filter(|character| !character.is_control()) + .take(MAX_COLLAB_DISCARDED_NODE_LABEL_CHARS) + .collect(); + let mut bounded = Vec::new(); + for field in fields { + if field.is_empty() || bounded.iter().any(|existing| existing == &field) { + continue; + } + bounded.push(field); + if bounded.len() == MAX_COLLAB_DISCARDED_FIELDS { + break; + } + } + Self { + node_label, + fields: bounded, + } + } +} + #[derive(Clone, PartialEq, Eq)] pub enum CollabUiAction { /// Open the local create-session choice screen. @@ -362,6 +425,8 @@ pub enum CollabUiAction { Retry, Leave, DiscardPending, + /// Resubmit the stashed conflict-discarded local edit as a new edit. + ReapplyDiscarded, SaveAsFork, ApproveAdmissionEditor { request_key: CollabAdmissionRequestKey, @@ -383,6 +448,8 @@ pub struct CollabUiState { pub panel: CollabPanelState, pub pending_edit: CollabPendingEditUi, pub notice: Option, + /// Set while the host runtime holds a replayable conflict-discarded edit. + pub discarded_edit: Option, pub pending_action: Option, authenticated: Option, pub(crate) public_session: CollabPublicSessionUi, @@ -401,6 +468,7 @@ impl Default for CollabUiState { panel: CollabPanelState::default(), pending_edit: CollabPendingEditUi::None, notice: None, + discarded_edit: None, pending_action: None, authenticated: None, public_session: CollabPublicSessionUi::default(), @@ -473,6 +541,7 @@ impl CollabUiState { self.authenticated = None; self.public_session = CollabPublicSessionUi::default(); self.clear_pending_admissions(); + self.discarded_edit = None; self.participants = Arc::new(Vec::new()); self.clear_presence(); } diff --git a/crates/op-editor-core/src/lib.rs b/crates/op-editor-core/src/lib.rs index b3fbf6f5d..72caf04e3 100644 --- a/crates/op-editor-core/src/lib.rs +++ b/crates/op-editor-core/src/lib.rs @@ -274,11 +274,12 @@ pub use collab_public_ui::{ }; pub use collab_ui_state::{ AuthenticatedCollabSession, CollabAvailability, CollabCanvasPoint, CollabConnectionPhase, - CollabNotice, CollabNoticeKind, CollabPanelState, CollabPanelView, CollabParticipantUi, - CollabPendingEditUi, CollabRejectUiCode, CollabShareEndpoint, CollabUiAction, CollabUiRole, - CollabUiState, DiscoveredCollabEndpoint, RemotePresenceUi, COLLAB_PRESENCE_FRAME_INTERVAL_MS, - MAX_COLLAB_DISPLAY_NAME_CHARS, MAX_COLLAB_SHARE_ENDPOINT_CHARS, MAX_COLLAB_UI_PARTICIPANTS, - MAX_COLLAB_UI_SELECTION_IDS, + CollabDiscardedEditUi, CollabNotice, CollabNoticeKind, CollabPanelState, CollabPanelView, + CollabParticipantUi, CollabPendingEditUi, CollabRejectUiCode, CollabShareEndpoint, + CollabUiAction, CollabUiRole, CollabUiState, DiscoveredCollabEndpoint, RemotePresenceUi, + COLLAB_PRESENCE_FRAME_INTERVAL_MS, MAX_COLLAB_DISCARDED_FIELDS, + MAX_COLLAB_DISCARDED_NODE_LABEL_CHARS, MAX_COLLAB_DISPLAY_NAME_CHARS, + MAX_COLLAB_SHARE_ENDPOINT_CHARS, MAX_COLLAB_UI_PARTICIPANTS, MAX_COLLAB_UI_SELECTION_IDS, }; pub use color_picker::{hsv_to_rgb, parse_hex_alpha, parse_hex_rgb, rgb_to_hex, rgb_to_hsv}; pub use command::{ diff --git a/crates/op-editor-ui/src/widgets/collab_panel_interaction.rs b/crates/op-editor-ui/src/widgets/collab_panel_interaction.rs index e36ddda79..7a5acad5c 100644 --- a/crates/op-editor-ui/src/widgets/collab_panel_interaction.rs +++ b/crates/op-editor-ui/src/widgets/collab_panel_interaction.rs @@ -381,6 +381,7 @@ fn hover_for_action(action: &CollabUiAction) -> Option { CollabUiAction::Retry => CollabPanelHover::Retry, CollabUiAction::Leave => CollabPanelHover::Leave, CollabUiAction::DiscardPending => CollabPanelHover::DiscardPending, + CollabUiAction::ReapplyDiscarded => CollabPanelHover::ReapplyDiscarded, CollabUiAction::SaveAsFork => CollabPanelHover::SaveAsFork, CollabUiAction::ApproveAdmissionEditor { .. } => CollabPanelHover::ApproveAdmissionEditor, CollabUiAction::ApproveAdmissionViewer { .. } => CollabPanelHover::ApproveAdmissionViewer, diff --git a/crates/op-editor-ui/src/widgets/collab_ui.rs b/crates/op-editor-ui/src/widgets/collab_ui.rs index b658890cd..3a2697742 100644 --- a/crates/op-editor-ui/src/widgets/collab_ui.rs +++ b/crates/op-editor-ui/src/widgets/collab_ui.rs @@ -297,6 +297,12 @@ fn panel_session_or_pre_auth( if collab.phase == CollabConnectionPhase::Reconnecting { actions.push(action_model(ui, CollabUiAction::Retry, true)); } + if collab.phase == CollabConnectionPhase::Active + && collab.discarded_edit.is_some() + && collab.pending_edit == CollabPendingEditUi::None + { + actions.push(action_model(ui, CollabUiAction::ReapplyDiscarded, true)); + } actions.push(action_model(ui, CollabUiAction::Leave, false)); } ( @@ -399,13 +405,26 @@ pub fn gate_reason_text(ui: &EditorUiState, reason: CollabGateReason) -> &'stati pub fn notice_text(ui: &EditorUiState, kind: op_editor_core::CollabNoticeKind) -> String { let message = op_i18n::translate(ui.locale, kind.i18n_key()); - if let op_editor_core::CollabNoticeKind::UnsupportedEdit(feature) = kind { - format!( - "{message} {}", - op_i18n::translate(ui.locale, feature.i18n_key()) - ) - } else { - message.to_string() + match kind { + op_editor_core::CollabNoticeKind::UnsupportedEdit(feature) => { + format!( + "{message} {}", + op_i18n::translate(ui.locale, feature.i18n_key()) + ) + } + // Only the cancellation that produced the stash names it; a plain + // `Reject(Conflict)` (for example the pending-edit gate) must not + // borrow an older discarded edit's detail. + op_editor_core::CollabNoticeKind::EditConflictDiscarded => { + let Some(discarded) = ui.collab.discarded_edit.as_ref() else { + return message.to_string(); + }; + let detail = op_i18n::translate(ui.locale, "collab.reject.conflictDetail") + .replace("{{fields}}", &discarded.fields.join(", ")) + .replace("{{node}}", &discarded.node_label); + format!("{message} {detail}") + } + _ => message.to_string(), } } diff --git a/crates/op-editor-ui/src/widgets/collab_ui_action.rs b/crates/op-editor-ui/src/widgets/collab_ui_action.rs index cb918d498..2884250b9 100644 --- a/crates/op-editor-ui/src/widgets/collab_ui_action.rs +++ b/crates/op-editor-ui/src/widgets/collab_ui_action.rs @@ -20,6 +20,7 @@ pub(super) fn action_model( CollabUiAction::Retry => "collab.action.retry", CollabUiAction::Leave => "collab.action.leave", CollabUiAction::DiscardPending => "collab.action.discardPending", + CollabUiAction::ReapplyDiscarded => "collab.action.reapply", CollabUiAction::SaveAsFork => "collab.action.saveAsFork", CollabUiAction::ApproveAdmissionEditor { .. } => "collab.action.approveEditor", CollabUiAction::ApproveAdmissionViewer { .. } => "collab.action.approveViewer", diff --git a/crates/op-editor-ui/src/widgets/collab_ui_debug.rs b/crates/op-editor-ui/src/widgets/collab_ui_debug.rs index 3308f9184..c9ad05296 100644 --- a/crates/op-editor-ui/src/widgets/collab_ui_debug.rs +++ b/crates/op-editor-ui/src/widgets/collab_ui_debug.rs @@ -56,6 +56,7 @@ impl std::fmt::Debug for CollabPanelActionModel { CollabUiAction::Retry => "Retry", CollabUiAction::Leave => "Leave", CollabUiAction::DiscardPending => "DiscardPending", + CollabUiAction::ReapplyDiscarded => "ReapplyDiscarded", CollabUiAction::SaveAsFork => "SaveAsFork", CollabUiAction::ApproveAdmissionEditor { .. } => "ApproveAdmissionEditor([REDACTED])", CollabUiAction::ApproveAdmissionViewer { .. } => "ApproveAdmissionViewer([REDACTED])", diff --git a/crates/op-editor-ui/src/widgets/collab_ui_tests.rs b/crates/op-editor-ui/src/widgets/collab_ui_tests.rs index 4faddaf23..f315f8a0a 100644 --- a/crates/op-editor-ui/src/widgets/collab_ui_tests.rs +++ b/crates/op-editor-ui/src/widgets/collab_ui_tests.rs @@ -209,3 +209,62 @@ fn guest_model_never_projects_owner_invite() { assert!(invite.is_none()); assert!(connection.is_some()); } + +#[test] +fn conflict_notice_names_the_discarded_fields_and_offers_reapply() { + use op_editor_core::{ + CollabDiscardedEditUi, CollabNoticeKind, CollabPendingEditUi, CollabUiRole, + }; + + let mut ui = EditorUiState::default(); + ui.collab.availability = CollabAvailability::Ready; + ui.collab.set_authenticated_session( + CollabConnectionPhase::Active, + AuthenticatedCollabSession { + session_name: "Design".into(), + role: CollabUiRole::Editor, + share_endpoint: None, + }, + Vec::new(), + ); + ui.collab.discarded_edit = Some(CollabDiscardedEditUi::bounded( + "Hero card", + ["x".to_string(), "x".to_string(), "fill".to_string()], + )); + ui.collab + .set_notice(CollabNoticeKind::EditConflictDiscarded, 7); + + let model = CollabPanelModel::for_editor_ui(&ui); + let notice = model.notice.expect("conflict notice is projected"); + assert!(notice.contains("x, fill"), "deduplicated fields: {notice}"); + assert!(notice.contains("Hero card"), "node label: {notice}"); + assert!(model + .actions + .iter() + .any(|action| action.action == CollabUiAction::ReapplyDiscarded)); + + // A plain conflict rejection (for example the pending-edit gate) never + // borrows the stashed detail. + ui.collab.set_notice( + CollabNoticeKind::Reject(op_editor_core::CollabRejectUiCode::Conflict), + 8, + ); + let plain = CollabPanelModel::for_editor_ui(&ui); + let plain_notice = plain.notice.expect("plain conflict notice is projected"); + assert!( + !plain_notice.contains("Hero card"), + "stale detail leaked: {plain_notice}" + ); + + // An in-flight edit hides the replay button until the lane is free. + ui.collab.pending_edit = CollabPendingEditUi::Submitting; + let busy = CollabPanelModel::for_editor_ui(&ui); + assert!(busy + .actions + .iter() + .all(|action| action.action != CollabUiAction::ReapplyDiscarded)); + + // Tearing the session down clears the stashed projection. + ui.collab.clear_authenticated(); + assert!(ui.collab.discarded_edit.is_none()); +} diff --git a/crates/op-host-desktop/src/collab_runtime.rs b/crates/op-host-desktop/src/collab_runtime.rs index 957a57c31..4cc74557b 100644 --- a/crates/op-host-desktop/src/collab_runtime.rs +++ b/crates/op-host-desktop/src/collab_runtime.rs @@ -70,6 +70,9 @@ pub(crate) struct DesktopCollabRuntime { pinned_owner_static: Option<[u8; 32]>, transaction_active: bool, save_as_fork_requested: bool, + /// Property changes of the most recent conflict-discarded local edit, + /// kept for a user-driven replay via `CollabUiAction::ReapplyDiscarded`. + discarded_property_edit: Option>, status: VecDeque, generation: u64, clock_start: Instant, @@ -137,6 +140,7 @@ impl DesktopCollabRuntime { pinned_owner_static: None, transaction_active: false, save_as_fork_requested: false, + discarded_property_edit: None, status: VecDeque::new(), generation: 1, clock_start: Instant::now(), @@ -197,6 +201,10 @@ impl DesktopCollabRuntime { Ok(()) } CollabUiAction::Retry => self.retry_guest(host), + CollabUiAction::ReapplyDiscarded => { + self.reapply_discarded(host); + Ok(()) + } CollabUiAction::SaveAsFork => { self.save_as_fork_requested = true; Ok(()) @@ -274,6 +282,40 @@ impl DesktopCollabRuntime { } } + /// Resubmit the stashed conflict-discarded property edit as a brand-new + /// local edit over the current authoritative document. + /// + /// The replay deliberately reasserts the dropped desired values over + /// whatever the fields hold now; it goes through the normal local-edit + /// pipeline, so a fresh conflict simply produces a fresh stash. + fn reapply_discarded(&mut self, host: &mut WidgetHostNative) { + if self.discarded_property_edit.is_none() { + return; + } + if !self.begin_local_edit(host) { + // Busy or read-only; begin_local_edit already raised the notice. + // The stash is kept so the user can retry once the lane is free. + return; + } + let changes = self + .discarded_property_edit + .take() + .expect("stash presence was checked above"); + host.editor_state_mut().editor_ui.collab.discarded_edit = None; + match op_collab::reapply_property_changes(&host.editor_state().doc, &changes) { + Ok(desired) => { + // Mutating the document inside the capture mirrors a GUI + // gesture: end_local_edit diffs it and bumps the revision. + host.editor_state_mut().doc = desired; + } + Err(_) => { + // The target node no longer exists; finish as a no-op. + self.set_notice(host, CollabNoticeKind::Reject(CollabRejectUiCode::Conflict)); + } + } + self.finish_local_edit(host); + } + /// Route Cmd/Ctrl+Z to M1 selective undo; `false` preserves standalone history. pub(crate) fn request_undo(&mut self, host: &mut WidgetHostNative) -> bool { let Some(mut actor) = self.actor.take() else { @@ -351,6 +393,7 @@ impl DesktopCollabRuntime { self.last_join = None; self.pinned_owner_static = None; self.transaction_active = false; + self.clear_discarded_stash(host); self.last_presence_sent = None; self.last_local_presence = None; self.pending_local_presence = None; @@ -696,6 +739,7 @@ impl DesktopCollabRuntime { self.actor = Some(EditorActor::Guest(guest)); self.retire_workers(); self.transaction_active = false; + self.clear_discarded_stash(host); self.set_notice(host, CollabNoticeKind::EpochChanged); self.push_status(CollabStatusEvent::SessionEnded); return Ok(()); diff --git a/crates/op-host-desktop/src/collab_runtime/effects.rs b/crates/op-host-desktop/src/collab_runtime/effects.rs index 769e0c75d..b2d717503 100644 --- a/crates/op-host-desktop/src/collab_runtime/effects.rs +++ b/crates/op-host-desktop/src/collab_runtime/effects.rs @@ -2,12 +2,13 @@ use std::sync::Arc; use std::time::{Duration, Instant}; use op_collab::{ - Bye, ByeReason, CollabMessage, ConnectionKey, GuestEffect, OwnerEffect, ParticipantPresence, - Point, Presence, UndoOutcome, UndoResult, Viewport, + Bye, ByeReason, CollabMessage, ConnectionKey, EditChanges, GuestEffect, NodeFieldChange, + OwnerEffect, ParticipantPresence, PendingCancelReason, Point, Presence, RejectCode, + UndoOutcome, UndoResult, Viewport, }; use op_editor_core::{ - CollabConnectErrorUi, CollabConnectionPhase, CollabNoticeKind, CollabPendingEditUi, - CollabRejectUiCode, DiscoveredCollabEndpoint, RemotePresenceUi, + CollabConnectErrorUi, CollabConnectionPhase, CollabDiscardedEditUi, CollabNoticeKind, + CollabPendingEditUi, CollabRejectUiCode, DiscoveredCollabEndpoint, RemotePresenceUi, }; use op_editor_host_core::collab::{ GuestEditorOutput, GuestLocalEditResolution, LocalEditResolution, OwnerEditorOutput, @@ -350,8 +351,10 @@ impl DesktopCollabRuntime { GuestEffect::VerifyRenewal { ticket } => { self.send_guest(GuestNetworkCommand::VerifyRenewal(ticket))?; } - GuestEffect::PendingCancelled { .. } => { - self.set_notice(host, CollabNoticeKind::Reject(CollabRejectUiCode::Conflict)); + GuestEffect::PendingCancelled { + reason, changes, .. + } => { + self.observe_pending_cancelled(reason, changes, host); } GuestEffect::UndoResult(result) => { self.observe_undo_result(&result, host); @@ -388,6 +391,12 @@ impl DesktopCollabRuntime { CollabConnectionPhase::Authenticating } }; + if phase == CollabConnectionPhase::Ended { + // Ended keeps the authenticated projection for the fork flow, so + // `clear_authenticated` never runs here — drop the replay stash + // explicitly before it can outlive the session that produced it. + self.clear_discarded_stash(host); + } if phase.is_authenticated() { set_guest_ui(host, guest, phase); self.publish_guest_connection_path(host); @@ -427,6 +436,70 @@ impl DesktopCollabRuntime { } } + /// A pending local edit was rolled back. `AlreadySatisfied` means the + /// authoritative history already contains the same values — nothing was + /// lost, so no toast. A genuine concurrency loss (property conflict or an + /// owner-side precondition failure) stashes the dropped property intent + /// for the panel's Reapply action and names it in the toast; every other + /// rejection maps to its own notice and clears any older stash, so a + /// policy or size rejection never offers to resubmit a forbidden edit. + fn observe_pending_cancelled( + &mut self, + reason: PendingCancelReason, + changes: EditChanges, + host: &mut WidgetHostNative, + ) { + let concurrency_loss = matches!( + reason, + PendingCancelReason::PropertyConflict { .. } + | PendingCancelReason::StructuralConflict + | PendingCancelReason::Rejected(RejectCode::PreconditionFailed) + ); + let notice = match reason { + PendingCancelReason::AlreadySatisfied => return, + PendingCancelReason::PropertyConflict { .. } + | PendingCancelReason::StructuralConflict + | PendingCancelReason::Rejected(RejectCode::PreconditionFailed) => { + CollabNoticeKind::Reject(CollabRejectUiCode::Conflict) + } + PendingCancelReason::Rejected(RejectCode::StaleBase) => { + CollabNoticeKind::Reject(CollabRejectUiCode::StaleBase) + } + PendingCancelReason::Rejected(RejectCode::PermissionDenied) => { + CollabNoticeKind::Reject(CollabRejectUiCode::ReadOnly) + } + PendingCancelReason::Rejected( + RejectCode::UnsupportedEdit | RejectCode::InvalidOperation, + ) => CollabNoticeKind::Reject(CollabRejectUiCode::Unsupported), + PendingCancelReason::Rejected(RejectCode::ResourceLimit) => { + CollabNoticeKind::Reject(CollabRejectUiCode::ResourceLimit) + } + PendingCancelReason::Rejected( + RejectCode::ExpiredClientOpId | RejectCode::CounterGap | RejectCode::SessionChanged, + ) => CollabNoticeKind::Reject(CollabRejectUiCode::Unknown), + }; + match changes { + EditChanges::Property(changes) if concurrency_loss && !changes.is_empty() => { + let projection = discarded_edit_projection(&changes, &host.editor_state().doc); + self.discarded_property_edit = Some(changes); + host.editor_state_mut().editor_ui.collab.discarded_edit = Some(projection); + // The stash-bearing notice kind is the only one whose text + // names the discarded node/fields. + self.set_notice(host, CollabNoticeKind::EditConflictDiscarded); + return; + } + _ => self.clear_discarded_stash(host), + } + self.set_notice(host, notice); + } + + /// Drop the replayable stash together with its display projection so the + /// two can never diverge. + pub(super) fn clear_discarded_stash(&mut self, host: &mut WidgetHostNative) { + self.discarded_property_edit = None; + host.editor_state_mut().editor_ui.collab.discarded_edit = None; + } + fn observe_undo_result(&self, result: &UndoResult, host: &mut WidgetHostNative) { if let Some(notice) = undo_notice(result.outcome, result.details.is_some()) { self.set_notice(host, notice); @@ -518,6 +591,7 @@ impl DesktopCollabRuntime { self.retire_workers(); self.pending_guest = None; self.transaction_active = false; + let mut session_ended = false; if let Some(EditorActor::Guest(guest)) = self.actor.as_mut() { let ended = guest.session.core().state() == op_collab::GuestConnectionState::Ended; @@ -529,6 +603,10 @@ impl DesktopCollabRuntime { set_guest_ui(host, guest, CollabConnectionPhase::Reconnecting); self.push_status(CollabStatusEvent::Reconnecting); } + session_ended = ended; + } + if session_ended { + self.clear_discarded_stash(host); } } None => { @@ -638,6 +716,7 @@ impl DesktopCollabRuntime { // Preserve OwnerLeft/Ended so Retry cannot replace the // required Save As fork flow. set_guest_ui(host, guest, CollabConnectionPhase::Ended); + self.clear_discarded_stash(host); } else { set_guest_ui(host, guest, CollabConnectionPhase::Reconnecting); self.set_notice( @@ -711,6 +790,49 @@ pub(super) fn command_send_error(error: NetworkCommandSendError) -> CollabRuntim } } +/// Bounded display projection of the dropped property changes: the layer +/// labels of every distinct target node (in change order) plus the +/// deduplicated field names, so a multi-node edit never attributes one +/// node's fields to another. +fn discarded_edit_projection( + changes: &[NodeFieldChange], + doc: &jian_ops_schema::PenDocument, +) -> CollabDiscardedEditUi { + let mut node_ids: Vec<&str> = Vec::new(); + for change in changes { + if !node_ids.contains(&change.node_id.as_str()) { + node_ids.push(change.node_id.as_str()); + } + } + let labels = node_ids + .iter() + .map(|node_id| node_display_label(doc, node_id)) + .collect::>() + .join(", "); + CollabDiscardedEditUi::bounded( + labels, + changes + .iter() + .map(|change| change.field.wire_name().to_string()), + ) +} + +/// Layer-panel label rules: authored node name, else the id as a last resort. +fn node_display_label(doc: &jian_ops_schema::PenDocument, node_id: &str) -> String { + use op_editor_core::PenNodeExt as _; + + let id = op_editor_core::NodeId::new(node_id); + let node = doc + .pages + .as_ref() + .into_iter() + .flatten() + .find_map(|page| op_editor_core::walkers::find_node(&page.children, &id)) + .or_else(|| op_editor_core::walkers::find_node(&doc.children, &id)); + node.and_then(|node| node.base().name.clone()) + .unwrap_or_else(|| node_id.to_owned()) +} + pub(super) fn disconnect_notice(failure: CollabRuntimeFailure) -> CollabNoticeKind { match failure { CollabRuntimeFailure::RelayInviteUnavailable => { @@ -774,6 +896,36 @@ mod tests { ); } + #[test] + fn discarded_projection_labels_every_distinct_node() { + let doc: jian_ops_schema::PenDocument = serde_json::from_value(serde_json::json!({ + "version": "1.0", + "children": [ + {"type": "rectangle", "id": "a", "name": "Alpha", "x": 0, "y": 0}, + {"type": "rectangle", "id": "b", "x": 0, "y": 0} + ] + })) + .unwrap(); + let change = |node_id: &str, field: op_collab::SupportedNodeField| NodeFieldChange { + page: op_collab::PageRef::DocumentRoot, + node_id: node_id.to_owned(), + field, + before: op_collab::FieldValue::Missing, + desired: op_collab::FieldValue::Value(serde_json::json!(1.0)), + }; + let projection = discarded_edit_projection( + &[ + change("a", op_collab::SupportedNodeField::X), + change("b", op_collab::SupportedNodeField::Y), + change("a", op_collab::SupportedNodeField::X), + ], + &doc, + ); + // Named node uses its layer name; a nameless node falls back to id. + assert_eq!(projection.node_label, "Alpha, b"); + assert_eq!(projection.fields, vec!["x".to_string(), "y".to_string()]); + } + #[test] fn only_single_source_guest_presence_is_coalesced() { let presence = Presence { diff --git a/crates/op-host-desktop/src/collab_runtime/tests.rs b/crates/op-host-desktop/src/collab_runtime/tests.rs index 10f72ed25..38c5e40c9 100644 --- a/crates/op-host-desktop/src/collab_runtime/tests.rs +++ b/crates/op-host-desktop/src/collab_runtime/tests.rs @@ -155,6 +155,24 @@ fn guest_runtime( Receiver, ConnectionKey, op_collab::Welcome, +) { + let (runtime, host, commands, connection, welcome, _owner, _owner_host) = + guest_runtime_with_owner(capacity); + (runtime, host, commands, connection, welcome) +} + +/// Like [`guest_runtime`], but keeps the authoring owner around so tests can +/// mint authentic authoritative commits against the shared session. +fn guest_runtime_with_owner( + capacity: usize, +) -> ( + DesktopCollabRuntime, + WidgetHostNative, + Receiver, + ConnectionKey, + op_collab::Welcome, + OwnerActor, + WidgetHostNative, ) { let mut owner_host = WidgetHostNative::new(); owner_host.editor_state_mut().doc = document_named("Before"); @@ -195,7 +213,9 @@ fn guest_runtime( let mut runtime = DesktopCollabRuntime::new(); runtime.network = Some(network); runtime.actor = Some(EditorActor::Guest(Box::new(guest))); - (runtime, host, commands, connection, welcome) + ( + runtime, host, commands, connection, welcome, owner, owner_host, + ) } #[test] @@ -672,6 +692,104 @@ fn retry_against_new_epoch_ends_without_replaying_pending_edit() { } } +#[test] +fn property_conflict_stashes_discarded_edit_and_reapply_resubmits_it() { + let (mut runtime, mut host, commands, connection, _welcome, mut owner, mut owner_host) = + guest_runtime_with_owner(8); + + // Guest optimistically renames the shared node. + assert!(runtime.begin_local_edit(&mut host)); + host.editor_state_mut().doc = document_named("Guest intent"); + assert!(runtime.finish_local_edit(&mut host)); + let _submit = commands.recv_timeout(Duration::from_secs(1)).unwrap(); + + // The owner concurrently renames the same field and wins seq 1. + owner.session.begin_local_edit(&owner_host).unwrap(); + owner_host.editor_state_mut().doc = document_named("Owner intent"); + let output = owner.session.finish_local_edit(&mut owner_host).unwrap(); + let commit = output + .effects + .iter() + .find_map(|effect| match effect { + op_collab::OwnerEffect::BroadcastCommit { commit } => Some(commit.as_ref().clone()), + _ => None, + }) + .expect("owner local edit broadcasts a commit"); + runtime.handle_event( + NetworkEvent::Frame { + connection, + frame: FrameEnvelope::new( + SessionId::from(SESSION), + Epoch(1), + CollabMessage::Commit(commit), + ), + }, + &mut host, + ); + + // The guest edit lost: the document rolls back to the owner version, but + // the dropped intent is stashed and named in the conflict projection. + assert_eq!( + canonical_document_hash(&host.editor_state().doc).unwrap(), + canonical_document_hash(&document_named("Owner intent")).unwrap() + ); + assert_eq!( + host.editor_state().editor_ui.collab.notice.unwrap().kind, + CollabNoticeKind::EditConflictDiscarded + ); + let discarded = host + .editor_state() + .editor_ui + .collab + .discarded_edit + .clone() + .expect("conflict stashes a replayable edit"); + assert!(discarded.fields.iter().any(|field| field == "name")); + + // While a gesture owns the edit lane, reapply refuses without + // consuming the stash so the user can retry later. + runtime.transaction_active = true; + host.editor_state_mut().editor_ui.collab.pending_action = + Some(op_editor_core::CollabUiAction::ReapplyDiscarded); + assert!(runtime.drain_ui_action(&mut host)); + assert!(runtime.discarded_property_edit.is_some()); + assert!(host + .editor_state() + .editor_ui + .collab + .discarded_edit + .is_some()); + runtime.transaction_active = false; + + // The user asks to reapply: the stash resubmits as a fresh local edit. + host.editor_state_mut().editor_ui.collab.pending_action = + Some(op_editor_core::CollabUiAction::ReapplyDiscarded); + assert!(runtime.drain_ui_action(&mut host)); + assert!(host + .editor_state() + .editor_ui + .collab + .discarded_edit + .is_none()); + assert_eq!( + canonical_document_hash(&host.editor_state().doc).unwrap(), + canonical_document_hash(&document_named("Guest intent")).unwrap() + ); + assert_eq!( + host.editor_state().editor_ui.collab.pending_edit, + CollabPendingEditUi::Submitting + ); + let mut saw_submit = false; + while let Ok(command) = commands.try_recv() { + if let GuestNetworkCommand::Send { frame, .. } = command { + if matches!(frame.decode_for_test().body(), CollabMessage::Submit(_)) { + saw_submit = true; + } + } + } + assert!(saw_submit, "reapply resubmits the stashed edit"); +} + #[test] fn outbound_bridge_budget_fails_reliable_and_drops_presence() { let (mut runtime, _host, commands, peer) = owner_runtime(8); diff --git a/crates/op-i18n/src/i18n/catalog_integrity_tests.rs b/crates/op-i18n/src/i18n/catalog_integrity_tests.rs index e0ab9bd04..62a93dcd5 100644 --- a/crates/op-i18n/src/i18n/catalog_integrity_tests.rs +++ b/crates/op-i18n/src/i18n/catalog_integrity_tests.rs @@ -251,7 +251,7 @@ fn placeholders(value: &str) -> BTreeSet { fn every_locale_has_exactly_the_english_key_set() { let all_tables = tables(); let expected = table_keys(all_tables[0].0, all_tables[0].1, all_tables[0].2); - assert_eq!(expected.len(), 1263, "update the intentional catalog size"); + assert_eq!(expected.len(), 1265, "update the intentional catalog size"); for (name, main, git, lookup) in all_tables { let actual = table_keys(name, main, git); @@ -280,7 +280,7 @@ fn collaboration_catalog_is_complete_in_all_fifteen_locales() { .collect(); assert_eq!( keys.len(), - 99, + 101, "update the intentional collaboration key set" ); for key in keys { diff --git a/crates/op-i18n/src/i18n/de_collab.rs b/crates/op-i18n/src/i18n/de_collab.rs index 1482f566b..84af8222f 100644 --- a/crates/op-i18n/src/i18n/de_collab.rs +++ b/crates/op-i18n/src/i18n/de_collab.rs @@ -71,6 +71,8 @@ pub fn lookup(key: &str) -> Option<&'static str> { "collab.reject.readOnly" => "Sie haben in dieser Sitzung nur Lesezugriff.", "collab.reject.unsupported" => "Der Eigentümer unterstützt diese Bearbeitung nicht.", "collab.reject.conflict" => "Diese Bearbeitung steht mit einer neueren Änderung in Konflikt.", + "collab.reject.conflictDetail" => "Verworfen: {{fields}} bei „{{node}}“.", + "collab.action.reapply" => "Meine Änderung erneut anwenden", "collab.reject.resourceLimit" => "Diese Bearbeitung überschreitet die Sitzungsgrenzen.", "collab.reject.authentication" => "Ihre Berechtigung zur Zusammenarbeit ist nicht mehr gültig.", "collab.reject.unknown" => "Der Eigentümer hat diese Bearbeitung abgelehnt.", diff --git a/crates/op-i18n/src/i18n/en_collab.rs b/crates/op-i18n/src/i18n/en_collab.rs index d11305a4e..cf2e96623 100644 --- a/crates/op-i18n/src/i18n/en_collab.rs +++ b/crates/op-i18n/src/i18n/en_collab.rs @@ -81,6 +81,8 @@ pub fn lookup(key: &str) -> Option<&'static str> { "collab.reject.readOnly" => "You have view-only access to this session.", "collab.reject.unsupported" => "The owner does not support that edit.", "collab.reject.conflict" => "That edit conflicts with a newer change.", + "collab.reject.conflictDetail" => "Discarded: {{fields}} on “{{node}}”.", + "collab.action.reapply" => "Reapply my edit", "collab.reject.resourceLimit" => "That edit is too large for this session.", "collab.reject.authentication" => "Your collaboration authorization is no longer valid.", "collab.reject.unknown" => "The owner rejected that edit.", diff --git a/crates/op-i18n/src/i18n/es_collab.rs b/crates/op-i18n/src/i18n/es_collab.rs index d84b060a8..559e0f587 100644 --- a/crates/op-i18n/src/i18n/es_collab.rs +++ b/crates/op-i18n/src/i18n/es_collab.rs @@ -71,6 +71,8 @@ pub fn lookup(key: &str) -> Option<&'static str> { "collab.reject.readOnly" => "Solo tienes acceso de lectura en esta sesión.", "collab.reject.unsupported" => "El propietario no admite esa edición.", "collab.reject.conflict" => "Esa edición entra en conflicto con un cambio más reciente.", + "collab.reject.conflictDetail" => "Descartado: {{fields}} en “{{node}}”.", + "collab.action.reapply" => "Reaplicar mi edición", "collab.reject.resourceLimit" => "Esa edición supera los límites de la sesión.", "collab.reject.authentication" => "Tu autorización de colaboración ya no es válida.", "collab.reject.unknown" => "El propietario rechazó esa edición.", diff --git a/crates/op-i18n/src/i18n/fr_collab.rs b/crates/op-i18n/src/i18n/fr_collab.rs index bd30256a0..3052857ba 100644 --- a/crates/op-i18n/src/i18n/fr_collab.rs +++ b/crates/op-i18n/src/i18n/fr_collab.rs @@ -71,6 +71,8 @@ pub fn lookup(key: &str) -> Option<&'static str> { "collab.reject.readOnly" => "Vous disposez uniquement d’un accès en lecture.", "collab.reject.unsupported" => "Le propriétaire ne prend pas en charge cette modification.", "collab.reject.conflict" => "Cette modification entre en conflit avec une version plus récente.", + "collab.reject.conflictDetail" => "Abandonné : {{fields}} sur « {{node}} ».", + "collab.action.reapply" => "Réappliquer ma modification", "collab.reject.resourceLimit" => "Cette modification dépasse les limites de la session.", "collab.reject.authentication" => "Votre autorisation collaborative n’est plus valide.", "collab.reject.unknown" => "Le propriétaire a refusé cette modification.", diff --git a/crates/op-i18n/src/i18n/hi_collab.rs b/crates/op-i18n/src/i18n/hi_collab.rs index f6b3f6ae8..1a6bfc770 100644 --- a/crates/op-i18n/src/i18n/hi_collab.rs +++ b/crates/op-i18n/src/i18n/hi_collab.rs @@ -73,6 +73,8 @@ pub fn lookup(key: &str) -> Option<&'static str> { "collab.reject.readOnly" => "इस सत्र में आपके पास केवल देखने की अनुमति है।", "collab.reject.unsupported" => "स्वामी उस संपादन का समर्थन नहीं करता।", "collab.reject.conflict" => "वह संपादन नए परिवर्तन से टकराता है।", + "collab.reject.conflictDetail" => "छोड़ा गया: “{{node}}” पर {{fields}}।", + "collab.action.reapply" => "मेरा संपादन फिर से लागू करें", "collab.reject.resourceLimit" => "वह संपादन सत्र की सीमा से बड़ा है।", "collab.reject.authentication" => "आपका सहयोग प्राधिकरण अब मान्य नहीं है।", "collab.reject.unknown" => "स्वामी ने वह संपादन अस्वीकार कर दिया।", diff --git a/crates/op-i18n/src/i18n/id_collab.rs b/crates/op-i18n/src/i18n/id_collab.rs index 7b046238d..d5ea25861 100644 --- a/crates/op-i18n/src/i18n/id_collab.rs +++ b/crates/op-i18n/src/i18n/id_collab.rs @@ -71,6 +71,8 @@ pub fn lookup(key: &str) -> Option<&'static str> { "collab.reject.readOnly" => "Anda hanya memiliki akses lihat dalam sesi ini.", "collab.reject.unsupported" => "Pemilik tidak mendukung edit tersebut.", "collab.reject.conflict" => "Edit tersebut bertentangan dengan perubahan yang lebih baru.", + "collab.reject.conflictDetail" => "Dibuang: {{fields}} pada “{{node}}”.", + "collab.action.reapply" => "Terapkan ulang suntingan saya", "collab.reject.resourceLimit" => "Edit tersebut melampaui batas sesi.", "collab.reject.authentication" => "Otorisasi kolaborasi Anda tidak lagi valid.", "collab.reject.unknown" => "Pemilik menolak edit tersebut.", diff --git a/crates/op-i18n/src/i18n/ja_collab.rs b/crates/op-i18n/src/i18n/ja_collab.rs index 8acaeae74..67cccae25 100644 --- a/crates/op-i18n/src/i18n/ja_collab.rs +++ b/crates/op-i18n/src/i18n/ja_collab.rs @@ -81,6 +81,8 @@ pub fn lookup(key: &str) -> Option<&'static str> { "collab.reject.readOnly" => "このセッションでは閲覧のみ可能です。", "collab.reject.unsupported" => "オーナーはこの編集に対応していません。", "collab.reject.conflict" => "この編集は新しい変更と競合しています。", + "collab.reject.conflictDetail" => "破棄: {{node}} の {{fields}}。", + "collab.action.reapply" => "自分の編集を再適用", "collab.reject.resourceLimit" => "この編集はセッションの上限を超えています。", "collab.reject.authentication" => "共同編集の認証が無効になりました。", "collab.reject.unknown" => "オーナーがこの編集を拒否しました。", diff --git a/crates/op-i18n/src/i18n/ko_collab.rs b/crates/op-i18n/src/i18n/ko_collab.rs index 35b8089d9..59898176f 100644 --- a/crates/op-i18n/src/i18n/ko_collab.rs +++ b/crates/op-i18n/src/i18n/ko_collab.rs @@ -79,6 +79,8 @@ pub fn lookup(key: &str) -> Option<&'static str> { "collab.reject.readOnly" => "이 세션에서는 보기 권한만 있습니다.", "collab.reject.unsupported" => "소유자가 이 편집을 지원하지 않습니다.", "collab.reject.conflict" => "이 편집이 최신 변경과 충돌합니다.", + "collab.reject.conflictDetail" => "삭제됨: {{node}}의 {{fields}}.", + "collab.action.reapply" => "내 편집 다시 적용", "collab.reject.resourceLimit" => "이 편집이 세션 제한을 초과합니다.", "collab.reject.authentication" => "협업 인증이 더 이상 유효하지 않습니다.", "collab.reject.unknown" => "소유자가 이 편집을 거부했습니다.", diff --git a/crates/op-i18n/src/i18n/pt_collab.rs b/crates/op-i18n/src/i18n/pt_collab.rs index fc811d9ed..fcc88f41f 100644 --- a/crates/op-i18n/src/i18n/pt_collab.rs +++ b/crates/op-i18n/src/i18n/pt_collab.rs @@ -71,6 +71,8 @@ pub fn lookup(key: &str) -> Option<&'static str> { "collab.reject.readOnly" => "Você tem apenas acesso de leitura nesta sessão.", "collab.reject.unsupported" => "O proprietário não aceita essa edição.", "collab.reject.conflict" => "Essa edição entra em conflito com uma alteração mais recente.", + "collab.reject.conflictDetail" => "Descartado: {{fields}} em “{{node}}”.", + "collab.action.reapply" => "Reaplicar minha edição", "collab.reject.resourceLimit" => "Essa edição ultrapassa os limites da sessão.", "collab.reject.authentication" => "Sua autorização de colaboração não é mais válida.", "collab.reject.unknown" => "O proprietário rejeitou essa edição.", diff --git a/crates/op-i18n/src/i18n/ru_collab.rs b/crates/op-i18n/src/i18n/ru_collab.rs index 66e964521..f0c7f3d29 100644 --- a/crates/op-i18n/src/i18n/ru_collab.rs +++ b/crates/op-i18n/src/i18n/ru_collab.rs @@ -83,6 +83,8 @@ pub fn lookup(key: &str) -> Option<&'static str> { "collab.reject.readOnly" => "В этом сеансе у вас доступ только для чтения.", "collab.reject.unsupported" => "Владелец не поддерживает это изменение.", "collab.reject.conflict" => "Изменение конфликтует с более новой версией.", + "collab.reject.conflictDetail" => "Отменено: {{fields}} у «{{node}}».", + "collab.action.reapply" => "Повторно применить моё изменение", "collab.reject.resourceLimit" => "Изменение превышает ограничения сеанса.", "collab.reject.authentication" => { "Разрешение на совместную работу больше не действительно." diff --git a/crates/op-i18n/src/i18n/th_collab.rs b/crates/op-i18n/src/i18n/th_collab.rs index 8bfafbfd5..642c03779 100644 --- a/crates/op-i18n/src/i18n/th_collab.rs +++ b/crates/op-i18n/src/i18n/th_collab.rs @@ -71,6 +71,8 @@ pub fn lookup(key: &str) -> Option<&'static str> { "collab.reject.readOnly" => "คุณมีสิทธิ์ดูเท่านั้นในเซสชันนี้", "collab.reject.unsupported" => "เจ้าของไม่รองรับการแก้ไขนี้", "collab.reject.conflict" => "การแก้ไขนี้ขัดแย้งกับการเปลี่ยนแปลงที่ใหม่กว่า", + "collab.reject.conflictDetail" => "ถูกละทิ้ง: {{fields}} ที่ “{{node}}”", + "collab.action.reapply" => "ใช้การแก้ไขของฉันอีกครั้ง", "collab.reject.resourceLimit" => "การแก้ไขนี้เกินขีดจำกัดของเซสชัน", "collab.reject.authentication" => "สิทธิ์การทำงานร่วมกันของคุณใช้ไม่ได้แล้ว", "collab.reject.unknown" => "เจ้าของปฏิเสธการแก้ไขนี้", diff --git a/crates/op-i18n/src/i18n/tr_collab.rs b/crates/op-i18n/src/i18n/tr_collab.rs index e4cc82416..71cbefb0e 100644 --- a/crates/op-i18n/src/i18n/tr_collab.rs +++ b/crates/op-i18n/src/i18n/tr_collab.rs @@ -71,6 +71,8 @@ pub fn lookup(key: &str) -> Option<&'static str> { "collab.reject.readOnly" => "Bu oturumda yalnızca görüntüleme erişiminiz var.", "collab.reject.unsupported" => "Sahip bu düzenlemeyi desteklemiyor.", "collab.reject.conflict" => "Bu düzenleme daha yeni bir değişiklikle çakışıyor.", + "collab.reject.conflictDetail" => "Atıldı: “{{node}}” üzerindeki {{fields}}.", + "collab.action.reapply" => "Düzenlememi yeniden uygula", "collab.reject.resourceLimit" => "Bu düzenleme oturum sınırlarını aşıyor.", "collab.reject.authentication" => "İşbirliği yetkiniz artık geçerli değil.", "collab.reject.unknown" => "Sahip bu düzenlemeyi reddetti.", diff --git a/crates/op-i18n/src/i18n/vi_collab.rs b/crates/op-i18n/src/i18n/vi_collab.rs index 5686e4eac..fbcaa1bad 100644 --- a/crates/op-i18n/src/i18n/vi_collab.rs +++ b/crates/op-i18n/src/i18n/vi_collab.rs @@ -81,6 +81,8 @@ pub fn lookup(key: &str) -> Option<&'static str> { "collab.reject.readOnly" => "Bạn chỉ có quyền xem trong phiên này.", "collab.reject.unsupported" => "Chủ sở hữu không hỗ trợ chỉnh sửa đó.", "collab.reject.conflict" => "Chỉnh sửa đó xung đột với thay đổi mới hơn.", + "collab.reject.conflictDetail" => "Đã loại bỏ: {{fields}} trên “{{node}}”.", + "collab.action.reapply" => "Áp dụng lại chỉnh sửa của tôi", "collab.reject.resourceLimit" => "Chỉnh sửa đó vượt giới hạn của phiên.", "collab.reject.authentication" => "Quyền cộng tác của bạn không còn hợp lệ.", "collab.reject.unknown" => "Chủ sở hữu đã từ chối chỉnh sửa đó.", diff --git a/crates/op-i18n/src/i18n/zh_cn_collab.rs b/crates/op-i18n/src/i18n/zh_cn_collab.rs index fd65b6383..16a221aa5 100644 --- a/crates/op-i18n/src/i18n/zh_cn_collab.rs +++ b/crates/op-i18n/src/i18n/zh_cn_collab.rs @@ -71,6 +71,8 @@ pub fn lookup(key: &str) -> Option<&'static str> { "collab.reject.readOnly" => "你在此会话中只有查看权限。", "collab.reject.unsupported" => "所有者不支持该编辑。", "collab.reject.conflict" => "该编辑与较新的更改冲突。", + "collab.reject.conflictDetail" => "已丢弃:{{node}} 的 {{fields}}。", + "collab.action.reapply" => "重新应用我的编辑", "collab.reject.resourceLimit" => "该编辑超出了会话大小限制。", "collab.reject.authentication" => "你的协作授权已失效。", "collab.reject.unknown" => "所有者拒绝了该编辑。", diff --git a/crates/op-i18n/src/i18n/zh_tw_collab.rs b/crates/op-i18n/src/i18n/zh_tw_collab.rs index 24917d6be..af6b27ff4 100644 --- a/crates/op-i18n/src/i18n/zh_tw_collab.rs +++ b/crates/op-i18n/src/i18n/zh_tw_collab.rs @@ -71,6 +71,8 @@ pub fn lookup(key: &str) -> Option<&'static str> { "collab.reject.readOnly" => "你在此工作階段中只有檢視權限。", "collab.reject.unsupported" => "擁有者不支援該編輯。", "collab.reject.conflict" => "該編輯與較新的變更衝突。", + "collab.reject.conflictDetail" => "已捨棄:{{node}} 的 {{fields}}。", + "collab.action.reapply" => "重新套用我的編輯", "collab.reject.resourceLimit" => "該編輯超出工作階段的大小限制。", "collab.reject.authentication" => "你的協作授權已失效。", "collab.reject.unknown" => "擁有者拒絕了該編輯。",