feat(editor): surface collab conflict details and replay discarded edits

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)
This commit is contained in:
Kayshen-X 2026-08-01 12:15:30 +08:00
parent b98154df15
commit 4c5f200b3d
36 changed files with 682 additions and 39 deletions

View file

@ -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",

View file

@ -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()?);

View file

@ -28,7 +28,7 @@ pub(crate) enum PendingRebase {
pub(crate) type CarriedPending = (
Arc<PenDocument>,
Option<PendingEdit>,
Option<(crate::ClientOpId, PendingCancelReason)>,
Option<crate::CancelledPendingEdit>,
);
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<Vec<GuestEffect>, 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(),
}),
)),
}
}

View file

@ -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,
);
}

View file

@ -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<PendingEdit>,
pub state: GuestConnectionState,
pub discard_buffer_through: Option<CommitSeq>,
pub pending_cancel: Option<(ClientOpId, PendingCancelReason)>,
pub pending_cancel: Option<CancelledPendingEdit>,
pub undo_index_add: Option<UndoIndexEntry>,
}
@ -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.

View file

@ -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,

View file

@ -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<PenDocument, ReapplyError> {
let mut grouped: BTreeMap<&str, Vec<NodeFieldChange>> = 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)
}

View file

@ -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();

View file

@ -18,6 +18,7 @@ pub enum CollabPanelHover {
Retry,
Leave,
DiscardPending,
ReapplyDiscarded,
SaveAsFork,
ApproveAdmissionEditor,
ApproveAdmissionViewer,

View file

@ -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])")

View file

@ -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<String>,
}
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<String>,
fields: impl IntoIterator<Item = String>,
) -> 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<CollabNotice>,
/// Set while the host runtime holds a replayable conflict-discarded edit.
pub discarded_edit: Option<CollabDiscardedEditUi>,
pub pending_action: Option<CollabUiAction>,
authenticated: Option<AuthenticatedCollabSession>,
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();
}

View file

@ -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::{

View file

@ -381,6 +381,7 @@ fn hover_for_action(action: &CollabUiAction) -> Option<CollabPanelHover> {
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,

View file

@ -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(),
}
}

View file

@ -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",

View file

@ -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])",

View file

@ -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());
}

View file

@ -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<Vec<op_collab::NodeFieldChange>>,
status: VecDeque<CollabStatusEvent>,
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(());

View file

@ -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::<Vec<_>>()
.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 {

View file

@ -155,6 +155,24 @@ fn guest_runtime(
Receiver<GuestNetworkCommand>,
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<GuestNetworkCommand>,
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);

View file

@ -251,7 +251,7 @@ fn placeholders(value: &str) -> BTreeSet<String> {
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 {

View file

@ -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.",

View file

@ -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.",

View file

@ -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.",

View file

@ -71,6 +71,8 @@ pub fn lookup(key: &str) -> Option<&'static str> {
"collab.reject.readOnly" => "Vous disposez uniquement dun 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 nest plus valide.",
"collab.reject.unknown" => "Le propriétaire a refusé cette modification.",

View file

@ -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" => "स्वामी ने वह संपादन अस्वीकार कर दिया।",

View file

@ -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.",

View file

@ -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" => "オーナーがこの編集を拒否しました。",

View file

@ -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" => "소유자가 이 편집을 거부했습니다.",

View file

@ -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.",

View file

@ -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" => {
"Разрешение на совместную работу больше не действительно."

View file

@ -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" => "เจ้าของปฏิเสธการแก้ไขนี้",

View file

@ -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ııyor.",
"collab.reject.authentication" => "İşbirliği yetkiniz artık geçerli değil.",
"collab.reject.unknown" => "Sahip bu düzenlemeyi reddetti.",

View file

@ -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 đó.",

View file

@ -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" => "所有者拒绝了该编辑。",

View file

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