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" => "擁有者拒絕了該編輯。",