editor-core: undo/redo by re-reducing the op-log prefix
The CRDT is delete-wins (a tombstone is permanent), so undo cannot invert an edit — it re-reduces the log without it. The materialized score is always reduce(applied onto base); undo drops the last unit (a primitive, or a whole transaction) so even a delete is undone — its tombstone is simply never produced. One user action is one unit; a transaction's descriptor and members undo together. redo re-appends the unit; a new edit forks history (clears the redo stack). undo/redo/can_undo/can_redo on EditorSession; a shared materialize/install path keeps the score, render, hit-test, and clefs in step. Undo must not let ids be reused (a streamed op would equivocate; an entity id would collide), so the session keeps a permanent append-only `authored` log beside the active `applied` prefix. New op-id counters come from authored.len() (monotonic), and event/pitch/transaction minting scans authored — so a unit's ids stay reserved after it is undone or forked away. The causal context is derived from the active head (active_prior_context + extend_context): it covers the whole active prefix, compact while contiguous and dot-based after a fork, so it never asserts coverage of a removed counter (which would strand a later op pending). with_identity now guards on authored history, and applied_operations is documented as the active prefix with authored_operations the full record. authored history is a local high-water source, not streaming-consistent undo. Tests: undo/redo a transpose; undo restores a deleted note; undo a split insert and a make-room overwrite as one unit; undo an override-aware move (value + spelling); a new edit forks history; a fork mints fresh op and entity ids (not the undone ones); edits after a fork re-reduce cleanly; with_identity is refused after an undone edit. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NAtEiJtt9yKVV1zjKYmZhS
This commit is contained in:
parent
872ff03dd5
commit
285d7bfd95
|
|
@ -29,10 +29,16 @@
|
|||
//! * **selection preservation** — re-resolve the selection against the new layout,
|
||||
//! keeping it when its layout object survives (the cursor does not jump off the
|
||||
//! edited object) and clearing it when the object is gone;
|
||||
//! * an **append-only operation log** ([`EditorSession::applied_operations`],
|
||||
//! [`EditorSession::last_applied`]) — every applied envelope, in order, the record
|
||||
//! undo, history, and sync build on (each intent feeds it automatically via
|
||||
//! `apply`).
|
||||
//! * **undo / redo** ([`EditorSession::undo`], [`EditorSession::redo`]) — re-reducing
|
||||
//! the active prefix of the op log without (or with) its last unit, so even a
|
||||
//! delete is undone (the CRDT is delete-wins, so a tombstone is re-reduced *away*
|
||||
//! rather than inverted);
|
||||
//! * two views of the op log: [`EditorSession::applied_operations`] (the
|
||||
//! currently-applied prefix, which shrinks on undo) and
|
||||
//! [`EditorSession::authored_operations`] (every envelope ever minted, the
|
||||
//! **append-only**, monotonic-id record). The authored log is a *local* high-water
|
||||
//! source, not streaming-consistent undo — a peer replaying it would re-apply
|
||||
//! undone units (undo is a local prefix change, not an operation).
|
||||
//!
|
||||
//! The session is **solver-agnostic**: it holds a `Box<dyn ConstraintSolver>`, so a
|
||||
//! GUI plugs in the real `Engraver`, the stub, or any conformant solver. It
|
||||
|
|
@ -247,14 +253,45 @@ pub struct EditorSession {
|
|||
start_clefs: BTreeMap<(RegionId, StaffId), Clef>,
|
||||
selection: Option<Selection>,
|
||||
// Operation-minting identity. A real client supplies its own replica/author.
|
||||
// Minted operations form this replica's contiguous, zero-based local history;
|
||||
// the next op's counter is `applied.len()`, so a failed apply consumes no id.
|
||||
// Minted operations form this replica's monotonic local history; the next op's
|
||||
// counter is `authored.len()` (never reused across undo), so a failed apply consumes
|
||||
// no id. The causal context covers the active prefix, not a fixed counter range, so a
|
||||
// fork (undo + new edit) does not strand a later op pending behind a removed unit.
|
||||
replica: ReplicaId,
|
||||
author: AuthorId,
|
||||
// Append-only log of the envelopes this session has applied, in order — the
|
||||
// record undo, sync, and history build on (every intent feeds it via `apply`),
|
||||
// and the input the session re-reduces onto `base` on each edit.
|
||||
// The currently-applied envelopes, in order — the log the session re-reduces onto
|
||||
// `base` to materialize `score`. One user action appends one unit (a primitive is
|
||||
// one envelope; a transaction is its descriptor plus members). Undo moves the last
|
||||
// unit to `redo_stack` and re-reduces the shorter prefix; a new edit clears the redo
|
||||
// stack. (A delete tombstones permanently in the CRDT, so undo cannot invert it —
|
||||
// it re-reduces *without* the delete instead, which is why the log drives the score
|
||||
// rather than a stack of inverse operations.)
|
||||
applied: Vec<OperationEnvelope>,
|
||||
// The size (envelope count) of each applied unit, in order, so undo can drop a whole
|
||||
// unit at once. `undo_units.iter().sum() == applied.len()`.
|
||||
undo_units: Vec<usize>,
|
||||
// Units undone and available to redo, most-recently-undone last (LIFO). Cleared by a
|
||||
// new edit; a redo re-appends the top unit and re-reduces.
|
||||
redo_stack: Vec<Vec<OperationEnvelope>>,
|
||||
// The append-only record of *every* envelope this session has ever minted — active,
|
||||
// undone, and forked-away. It never shrinks, so it is the monotonic high-water source
|
||||
// for new operation, event, pitch, and transaction ids: undoing an edit must not let
|
||||
// a later edit re-mint its ids (which would equivocate a streamed op or collide an
|
||||
// entity id). A new op's counter is `authored.len()`; minting scans this, not the
|
||||
// active prefix. (`applied` is always a — possibly non-contiguous, after a fork —
|
||||
// subsequence of `authored`.)
|
||||
authored: Vec<OperationEnvelope>,
|
||||
}
|
||||
|
||||
/// One materialization of the op log: the reduced score and everything derived from it
|
||||
/// that the session installs together (so the render, hit-test, and clef table never
|
||||
/// disagree with the score). Produced by [`EditorSession::materialize`].
|
||||
struct Materialization {
|
||||
score: Score,
|
||||
start_clefs: BTreeMap<(RegionId, StaffId), Clef>,
|
||||
resolved: ResolvedLayoutIR,
|
||||
render: RenderIR,
|
||||
map: HitTestMap,
|
||||
}
|
||||
|
||||
impl EditorSession {
|
||||
|
|
@ -275,23 +312,26 @@ impl EditorSession {
|
|||
replica: ReplicaId(1),
|
||||
author: AuthorId(0),
|
||||
applied: Vec::new(),
|
||||
undo_units: Vec::new(),
|
||||
redo_stack: Vec::new(),
|
||||
authored: Vec::new(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Overrides the replica/author the session mints operations under (a GUI sets
|
||||
/// these to the local editing identity). Defaults to `ReplicaId(1)` / author 0.
|
||||
///
|
||||
/// **Pre-edit only** — panics if called after an [`Self::apply`]. A session's op
|
||||
/// log is one replica's contiguous, zero-based history (each counter is
|
||||
/// `applied.len()`); switching identity mid-stream would continue the counter
|
||||
/// under a new replica, leaving a `(new_replica, 0)` hole that the
|
||||
/// missing-predecessor rule would hold pending. The identity is therefore fixed
|
||||
/// before the first edit.
|
||||
/// **Pre-edit only** — panics if called after any edit (including ones since undone).
|
||||
/// A session's op ids are one replica's monotonic history; switching identity
|
||||
/// mid-stream would continue the counter under a new replica, leaving a
|
||||
/// `(new_replica, 0)` hole that the missing-predecessor rule would hold pending. The
|
||||
/// guard is on the **authored** history (every id ever minted), not the active
|
||||
/// prefix, so undoing back to an empty prefix does not reopen it.
|
||||
pub fn with_identity(mut self, replica: ReplicaId, author: AuthorId) -> Self {
|
||||
assert!(
|
||||
self.applied.is_empty(),
|
||||
self.authored.is_empty(),
|
||||
"with_identity must be set before any edit: a session's op log is a \
|
||||
single replica's contiguous history"
|
||||
single replica's monotonic history"
|
||||
);
|
||||
self.replica = replica;
|
||||
self.author = author;
|
||||
|
|
@ -325,14 +365,23 @@ impl EditorSession {
|
|||
self.selection
|
||||
}
|
||||
|
||||
/// The operations this session has applied, oldest first — an append-only log
|
||||
/// for undo, history, and sync (a client streams these to peers; an undo layer
|
||||
/// rebuilds or inverts from them). Only successfully-applied edits appear; a
|
||||
/// rejected or non-renderable edit leaves the log untouched.
|
||||
/// The operations **currently applied**, oldest first — the active prefix the
|
||||
/// materialized score reduces from. It grows by one unit per successful edit and
|
||||
/// **shrinks on [`undo`](Self::undo)** (a redo re-extends it), so it is not an
|
||||
/// append-only history; the session keeps that separately as the monotonic id
|
||||
/// source. A rejected or non-renderable edit leaves it untouched.
|
||||
pub fn applied_operations(&self) -> &[OperationEnvelope] {
|
||||
&self.applied
|
||||
}
|
||||
|
||||
/// Every envelope this session has ever minted, in mint order — the **append-only**
|
||||
/// authored history, including units since undone or forked away by a later edit.
|
||||
/// This is the monotonic high-water source that keeps ids unique; the currently
|
||||
/// applied subset is [`applied_operations`](Self::applied_operations).
|
||||
pub fn authored_operations(&self) -> &[OperationEnvelope] {
|
||||
&self.authored
|
||||
}
|
||||
|
||||
/// The most recently applied operation, if any (the tail of
|
||||
/// [`Self::applied_operations`]).
|
||||
pub fn last_applied(&self) -> Option<&OperationEnvelope> {
|
||||
|
|
@ -533,30 +582,18 @@ impl EditorSession {
|
|||
}
|
||||
|
||||
/// Builds an [`OperationEnvelope`] at operation `counter` under the session's
|
||||
/// identity, carrying `payload` and an optional `transaction` membership — the
|
||||
/// bookkeeping (id, author, stamp, causal context) a GUI would otherwise assemble
|
||||
/// by hand. Pure: it reads no mutable state, so a failed apply consumes no id.
|
||||
///
|
||||
/// The causal context makes the session's edits a single replica's contiguous,
|
||||
/// zero-based history: the op at `counter` covers every prior local op (the
|
||||
/// range `[0, counter - 1]`). Two sequential edits to the same target therefore
|
||||
/// read as intentional overwrites — the later covering the earlier — rather than
|
||||
/// concurrent conflicting edits, both when this session re-reduces its own log
|
||||
/// and when a peer replays it. The first op (counter 0) is a root: an empty
|
||||
/// context. This also gives transaction members descriptor-precedence for free:
|
||||
/// a member at a later counter covers the descriptor minted before it.
|
||||
/// identity, carrying `payload`, an optional `transaction` membership, and the
|
||||
/// `causal_context` the caller derived from the active head — the bookkeeping a GUI
|
||||
/// would otherwise assemble by hand. Pure: it reads no mutable state, so a failed
|
||||
/// apply consumes no id.
|
||||
fn envelope_at(
|
||||
&self,
|
||||
counter: u64,
|
||||
payload: OperationPayload,
|
||||
transaction: Option<TransactionId>,
|
||||
causal_context: CausalContext,
|
||||
) -> OperationEnvelope {
|
||||
let id = OperationId::new(self.replica, counter);
|
||||
let causal_context = if counter > 0 {
|
||||
CausalContext::new().with_seen(self.replica, counter - 1)
|
||||
} else {
|
||||
CausalContext::new()
|
||||
};
|
||||
OperationEnvelope {
|
||||
id,
|
||||
author: self.author,
|
||||
|
|
@ -570,9 +607,20 @@ impl EditorSession {
|
|||
}
|
||||
}
|
||||
|
||||
/// Builds a standalone primitive envelope for `kind` at `counter`.
|
||||
fn envelope_for(&self, counter: u64, kind: OperationKind) -> OperationEnvelope {
|
||||
self.envelope_at(counter, OperationPayload::Primitive(kind), None)
|
||||
/// The causal context a newly-minted op carries: it covers every **currently
|
||||
/// applied** op (so two sequential same-target edits read as intentional overwrites,
|
||||
/// the later covering the earlier, rather than concurrent conflicts). Derived from
|
||||
/// the active head's own context plus the head, so it covers the whole active prefix
|
||||
/// without asserting coverage of any unit since undone or forked away — which would
|
||||
/// otherwise leave the new op held pending behind a missing predecessor. Empty when
|
||||
/// nothing is applied (a root op). The compact contiguous form is preserved while
|
||||
/// the active prefix has no holes (the common case); a fork records the active tail
|
||||
/// as dots (see [`extend_context`]).
|
||||
fn active_prior_context(&self) -> CausalContext {
|
||||
match self.applied.last() {
|
||||
None => CausalContext::new(),
|
||||
Some(head) => extend_context(head.causal_context.clone(), head.id),
|
||||
}
|
||||
}
|
||||
|
||||
/// Accepts `new` envelopes on top of the prior log, reduces the whole set onto
|
||||
|
|
@ -590,50 +638,124 @@ impl EditorSession {
|
|||
/// the whole log each edit is fine at editor scale; incremental reduction is a
|
||||
/// later optimization.)
|
||||
fn commit(&mut self, new: Vec<OperationEnvelope>) -> Result<EditOutcome, EditorError> {
|
||||
// Re-accept the prior log (idempotent — they were accepted before), then the
|
||||
// new envelopes. One the reducer will not accept (e.g. a reserved replica
|
||||
// identity) must not partially apply.
|
||||
let mut set = OperationSet::new();
|
||||
for prior in &self.applied {
|
||||
set.accept(prior.clone());
|
||||
let unit = new.len();
|
||||
let kept = self.applied.len();
|
||||
// Append the unit tentatively and materialize the whole prefix; on any failure
|
||||
// (a rejected op, a rolled-back transaction, a diagnostic-only layout) truncate
|
||||
// back so the failed edit leaves all state — the op log included — untouched.
|
||||
self.applied.extend(new.iter().cloned());
|
||||
match self.materialize(&self.applied) {
|
||||
Ok(materialized) => {
|
||||
let graph_changed = materialized.score != self.score;
|
||||
// Record the unit permanently (the monotonic id source) and as the new
|
||||
// active tail; a new edit forks history past any undos.
|
||||
self.authored.extend(new);
|
||||
self.undo_units.push(unit);
|
||||
self.redo_stack.clear();
|
||||
self.install(materialized);
|
||||
let selection_preserved = self.reresolve_selection();
|
||||
Ok(EditOutcome {
|
||||
graph_changed,
|
||||
selection_preserved,
|
||||
})
|
||||
}
|
||||
Err(err) => {
|
||||
self.applied.truncate(kept);
|
||||
Err(err)
|
||||
}
|
||||
}
|
||||
for env in &new {
|
||||
}
|
||||
|
||||
/// Undoes the most recent edit (one user action — a primitive, or a whole
|
||||
/// transaction). Re-reduces the log without that unit, so even a delete is undone
|
||||
/// (its tombstone is simply never produced). The unit moves to the redo stack.
|
||||
/// `None` if there is nothing to undo.
|
||||
pub fn undo(&mut self) -> Option<EditOutcome> {
|
||||
let unit = *self.undo_units.last()?;
|
||||
let cut = self.applied.len() - unit;
|
||||
// A prefix of a valid log is itself valid (units are dropped from the end, so
|
||||
// every remaining op's causal predecessors are still present), so this succeeds.
|
||||
let materialized = self.materialize(&self.applied[..cut]).ok()?;
|
||||
let graph_changed = materialized.score != self.score;
|
||||
let undone = self.applied.split_off(cut);
|
||||
self.redo_stack.push(undone);
|
||||
self.undo_units.pop();
|
||||
self.install(materialized);
|
||||
let selection_preserved = self.reresolve_selection();
|
||||
Some(EditOutcome {
|
||||
graph_changed,
|
||||
selection_preserved,
|
||||
})
|
||||
}
|
||||
|
||||
/// Redoes the most recently undone edit, re-appending its unit and re-reducing.
|
||||
/// `None` if there is nothing to redo (a new edit clears the redo stack).
|
||||
pub fn redo(&mut self) -> Option<EditOutcome> {
|
||||
let unit = self.redo_stack.last()?.clone();
|
||||
let kept = self.applied.len();
|
||||
self.applied.extend(unit.iter().cloned());
|
||||
let Ok(materialized) = self.materialize(&self.applied) else {
|
||||
self.applied.truncate(kept); // leave the redo stack intact on the (unexpected) failure
|
||||
return None;
|
||||
};
|
||||
let graph_changed = materialized.score != self.score;
|
||||
self.redo_stack.pop();
|
||||
self.undo_units.push(unit.len());
|
||||
self.install(materialized);
|
||||
let selection_preserved = self.reresolve_selection();
|
||||
Some(EditOutcome {
|
||||
graph_changed,
|
||||
selection_preserved,
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether there is an applied edit to [`undo`](Self::undo).
|
||||
pub fn can_undo(&self) -> bool {
|
||||
!self.undo_units.is_empty()
|
||||
}
|
||||
|
||||
/// Whether there is an undone edit to [`redo`](Self::redo).
|
||||
pub fn can_redo(&self) -> bool {
|
||||
!self.redo_stack.is_empty()
|
||||
}
|
||||
|
||||
/// Reduces `log` onto `base` and renders it — the materialization every edit, undo,
|
||||
/// and redo installs. Errors with [`EditorError::RejectedOperation`] if an op does
|
||||
/// not accept or the reduction is not clean (a transaction whose members fail rolls
|
||||
/// back as a conflict yet still returns a score), or [`EditorError::NotRenderable`]
|
||||
/// for a diagnostic-only layout.
|
||||
fn materialize(&self, log: &[OperationEnvelope]) -> Result<Materialization, EditorError> {
|
||||
let mut set = OperationSet::new();
|
||||
for env in log {
|
||||
if !matches!(set.accept(env.clone()), AcceptOutcome::Accepted) {
|
||||
return Err(EditorError::RejectedOperation);
|
||||
}
|
||||
}
|
||||
let materialized = set.reduce_onto(&self.base);
|
||||
// A non-clean reduction means the edit did not apply cleanly: an atomic
|
||||
// transaction whose member preconditions fail rolls back as a conflict, yet
|
||||
// the reducer still returns a score. Refuse rather than log an edit that did
|
||||
// not take effect. (Committed edits are kept clean, so any conflict/pending is
|
||||
// introduced by `new`.)
|
||||
if !materialized.state.is_clean() {
|
||||
return Err(EditorError::RejectedOperation);
|
||||
}
|
||||
let edited = materialized.score;
|
||||
let graph_changed = edited != self.score;
|
||||
|
||||
// Refuse a diagnostic-only layout, still before committing anything.
|
||||
let score = materialized.score;
|
||||
let (start_clefs, resolved, render, map) =
|
||||
render_score(&edited, self.solver.as_ref()).ok_or(EditorError::NotRenderable)?;
|
||||
|
||||
// Commit (the only mutation point — so an error above leaves all state,
|
||||
// op log included, untouched).
|
||||
self.score = edited;
|
||||
self.resolved = resolved;
|
||||
self.render = render;
|
||||
self.map = map;
|
||||
self.start_clefs = start_clefs;
|
||||
self.applied.extend(new);
|
||||
|
||||
let selection_preserved = self.reresolve_selection();
|
||||
Ok(EditOutcome {
|
||||
graph_changed,
|
||||
selection_preserved,
|
||||
render_score(&score, self.solver.as_ref()).ok_or(EditorError::NotRenderable)?;
|
||||
Ok(Materialization {
|
||||
score,
|
||||
start_clefs,
|
||||
resolved,
|
||||
render,
|
||||
map,
|
||||
})
|
||||
}
|
||||
|
||||
/// Installs a materialization as the session's current score, layout, and render.
|
||||
fn install(&mut self, materialized: Materialization) {
|
||||
self.score = materialized.score;
|
||||
self.start_clefs = materialized.start_clefs;
|
||||
self.resolved = materialized.resolved;
|
||||
self.render = materialized.render;
|
||||
self.map = materialized.map;
|
||||
}
|
||||
|
||||
/// Applies a single primitive operation: mints an envelope and commits it. A
|
||||
/// [`OperationKind::DeclareTransaction`] is not a primitive mutation — the session
|
||||
/// declares transactions via [`Self::apply_transaction`] — so it is refused here.
|
||||
|
|
@ -641,8 +763,15 @@ impl EditorSession {
|
|||
if matches!(kind, OperationKind::DeclareTransaction(_)) {
|
||||
return Err(EditorError::DeclareTransactionNotAllowed);
|
||||
}
|
||||
let counter = self.applied.len() as u64;
|
||||
let envelope = self.envelope_for(counter, kind);
|
||||
// The next id is one past every id ever minted (monotonic across undo), and the
|
||||
// context covers the currently-applied ops.
|
||||
let counter = self.authored.len() as u64;
|
||||
let envelope = self.envelope_at(
|
||||
counter,
|
||||
OperationPayload::Primitive(kind),
|
||||
None,
|
||||
self.active_prior_context(),
|
||||
);
|
||||
self.commit(vec![envelope])
|
||||
}
|
||||
|
||||
|
|
@ -680,16 +809,17 @@ impl EditorSession {
|
|||
|
||||
/// Mints the envelopes for an atomic transaction: a `DeclareTransaction`
|
||||
/// descriptor at the next counter, then one member envelope per kind at the
|
||||
/// following counters, each referencing the transaction id. The contiguous causal
|
||||
/// context gives every member descriptor-precedence over the descriptor for free.
|
||||
/// Pure.
|
||||
/// following counters, each referencing the transaction id. Each envelope's context
|
||||
/// covers the ones before it (the active prefix, then the descriptor, then earlier
|
||||
/// members), which gives every member descriptor-precedence over the descriptor for
|
||||
/// free. Pure.
|
||||
fn transaction_envelopes(
|
||||
&self,
|
||||
label: &str,
|
||||
category: Option<TransactionCategory>,
|
||||
kinds: Vec<OperationKind>,
|
||||
) -> Vec<OperationEnvelope> {
|
||||
let base = self.applied.len() as u64;
|
||||
let base = self.authored.len() as u64;
|
||||
let tx_id = self.mint_transaction_id();
|
||||
let descriptor = TransactionDescriptor {
|
||||
id: tx_id,
|
||||
|
|
@ -697,29 +827,39 @@ impl EditorSession {
|
|||
category,
|
||||
};
|
||||
let mut envelopes = Vec::with_capacity(kinds.len() + 1);
|
||||
envelopes.push(self.envelope_at(
|
||||
// Thread the context: each envelope sees the active prefix plus every earlier
|
||||
// envelope in this unit.
|
||||
let mut context = self.active_prior_context();
|
||||
let descriptor_env = self.envelope_at(
|
||||
base,
|
||||
OperationPayload::Primitive(OperationKind::DeclareTransaction(descriptor)),
|
||||
None,
|
||||
));
|
||||
context.clone(),
|
||||
);
|
||||
context = extend_context(context, descriptor_env.id);
|
||||
envelopes.push(descriptor_env);
|
||||
for (i, kind) in kinds.into_iter().enumerate() {
|
||||
let counter = base + 1 + i as u64;
|
||||
envelopes.push(self.envelope_at(
|
||||
let member = self.envelope_at(
|
||||
counter,
|
||||
OperationPayload::Primitive(kind),
|
||||
Some(tx_id),
|
||||
));
|
||||
context.clone(),
|
||||
);
|
||||
context = extend_context(context, member.id);
|
||||
envelopes.push(member);
|
||||
}
|
||||
envelopes
|
||||
}
|
||||
|
||||
/// Mints a fresh [`TransactionId`] in the session's replica namespace, one past
|
||||
/// the highest transaction counter declared in this session's op log. (Transaction
|
||||
/// ids live only in the op stream — the materialized score retains no trace — so
|
||||
/// the log is the sole source.)
|
||||
/// Mints a fresh [`TransactionId`] in the session's replica namespace, one past the
|
||||
/// highest transaction counter declared in this session's **authored** history
|
||||
/// (so an undone transaction's id is never reused). Transaction ids live only in the
|
||||
/// op stream — the materialized score retains no trace — so the log is the sole
|
||||
/// source.
|
||||
fn mint_transaction_id(&self) -> TransactionId {
|
||||
let next = self
|
||||
.applied
|
||||
.authored
|
||||
.iter()
|
||||
.filter_map(declared_transaction_id)
|
||||
.filter(|t| t.replica() == self.replica)
|
||||
|
|
@ -1348,7 +1488,8 @@ impl EditorSession {
|
|||
|
||||
/// Mints a fresh [`EventId`] in the session's replica namespace, on the same
|
||||
/// three-source high-water-mark basis as [`Self::mint_pitch_id`]: the pristine
|
||||
/// `base`, the current score (each live or tombstoned), and this session's op log.
|
||||
/// `base`, the current score (each live or tombstoned), and this session's
|
||||
/// **authored** history (so an id from an undone insert is never reused).
|
||||
fn mint_event_id(&self) -> EventId {
|
||||
let ids = self
|
||||
.base
|
||||
|
|
@ -1358,7 +1499,7 @@ impl EditorSession {
|
|||
.chain(self.base.tombstoned_events.iter().copied())
|
||||
.chain(self.score.events.iter().map(Event::id))
|
||||
.chain(self.score.tombstoned_events.iter().copied())
|
||||
.chain(self.applied.iter().flat_map(inserted_event_ids));
|
||||
.chain(self.authored.iter().flat_map(inserted_event_ids));
|
||||
let next = ids
|
||||
.filter(|e| e.replica() == self.replica)
|
||||
.map(|e| e.counter())
|
||||
|
|
@ -1395,10 +1536,10 @@ impl EditorSession {
|
|||
/// tombstones only reducer state, never `Score.tombstoned_pitches` — so the
|
||||
/// high-water mark is taken over three sources: the pristine open-time `base`
|
||||
/// (catches an opened pitch since deleted), the current score (catches anything a
|
||||
/// future reducer change records), and this session's op log (catches a
|
||||
/// session-inserted pitch since deleted). Reusing an id would make a later insert
|
||||
/// no-op against a tombstone under whole-log reduction. Pitches authored by other
|
||||
/// replicas occupy disjoint namespaces and do not constrain it.
|
||||
/// future reducer change records), and this session's **authored** history (catches
|
||||
/// a session-inserted pitch since deleted *or undone*). Reusing an id would make a
|
||||
/// later insert no-op against a tombstone under whole-log reduction. Pitches authored
|
||||
/// by other replicas occupy disjoint namespaces and do not constrain it.
|
||||
fn mint_pitch_id(&self) -> PitchId {
|
||||
let ids = self
|
||||
.base
|
||||
|
|
@ -1407,7 +1548,7 @@ impl EditorSession {
|
|||
.chain(self.base.tombstoned_pitches.iter().copied())
|
||||
.chain(self.score.live_pitch_ids())
|
||||
.chain(self.score.tombstoned_pitches.iter().copied())
|
||||
.chain(self.applied.iter().flat_map(inserted_pitch_ids));
|
||||
.chain(self.authored.iter().flat_map(inserted_pitch_ids));
|
||||
let next = ids
|
||||
.filter(|p| p.replica() == self.replica)
|
||||
.map(|p| p.counter())
|
||||
|
|
@ -1760,6 +1901,25 @@ fn declared_transaction_id(env: &OperationEnvelope) -> Option<TransactionId> {
|
|||
}
|
||||
}
|
||||
|
||||
/// Extends a causal context to also cover `op`. When `op` continues `op.replica`'s
|
||||
/// contiguous range (the common case — a session with no undo mints `0, 1, 2, …`), it
|
||||
/// folds into the compact version vector, reproducing the simple `with_seen(replica,
|
||||
/// counter)` history; once a fork (undo + new edit) leaves a gap below `op.counter`, it
|
||||
/// is recorded as an individual dot so the context covers the active op without
|
||||
/// asserting — and stranding the new op pending behind — the forked-away counters in
|
||||
/// between.
|
||||
fn extend_context(context: CausalContext, op: OperationId) -> CausalContext {
|
||||
let continues = context
|
||||
.vector
|
||||
.get(&op.replica)
|
||||
.map_or(op.counter == 0, |&high| op.counter == high + 1);
|
||||
if continues {
|
||||
context.with_seen(op.replica, op.counter)
|
||||
} else {
|
||||
context.with_dot(op)
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether two metric spans overlap — `[a_pos, a_pos + a_dur)` against `[b_pos,
|
||||
/// b_pos + b_dur)`. Mirrors the reducer's `intervals_overlap` (non-positive spans
|
||||
/// never overlap), so the editor's pre-check matches the reducer's decision.
|
||||
|
|
@ -3930,6 +4090,306 @@ mod tests {
|
|||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undo_and_redo_a_transpose() {
|
||||
let mut session = open_rich(0x5EED);
|
||||
let selection = click_a_notehead(&mut session);
|
||||
let TypedObjectId::Pitch(pid) = selection.source else {
|
||||
panic!("a notehead selects a pitch");
|
||||
};
|
||||
let before = session.current_pitch(pid).unwrap();
|
||||
session.transpose_selection(1).expect("sharpen");
|
||||
let after = session.current_pitch(pid).unwrap();
|
||||
assert_ne!(before, after);
|
||||
assert!(session.can_undo() && !session.can_redo());
|
||||
|
||||
let outcome = session.undo().expect("undo");
|
||||
assert!(outcome.graph_changed);
|
||||
assert_eq!(
|
||||
session.current_pitch(pid).unwrap(),
|
||||
before,
|
||||
"undo restores the value"
|
||||
);
|
||||
assert!(!session.can_undo() && session.can_redo());
|
||||
|
||||
session.redo().expect("redo");
|
||||
assert_eq!(
|
||||
session.current_pitch(pid).unwrap(),
|
||||
after,
|
||||
"redo re-applies the edit"
|
||||
);
|
||||
assert!(session.can_undo() && !session.can_redo());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undo_restores_a_deleted_note() {
|
||||
// The CRDT is delete-wins (a tombstone is permanent), so undo cannot *invert* a
|
||||
// delete — it re-reduces the log without it, and the note reappears.
|
||||
let mut session = open_rich(0x5EED);
|
||||
let selection = click_a_notehead(&mut session);
|
||||
let TypedObjectId::Pitch(pid) = selection.source else {
|
||||
panic!("a notehead selects a pitch");
|
||||
};
|
||||
assert!(session.score().live_pitch_ids().contains(&pid));
|
||||
session.delete_selection().expect("delete");
|
||||
assert!(!session.score().live_pitch_ids().contains(&pid), "deleted");
|
||||
|
||||
session.undo().expect("undo");
|
||||
assert!(
|
||||
session.score().live_pitch_ids().contains(&pid),
|
||||
"undo brings the deleted note back"
|
||||
);
|
||||
session.redo().expect("redo");
|
||||
assert!(
|
||||
!session.score().live_pitch_ids().contains(&pid),
|
||||
"redo deletes it again"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undo_reverts_a_split_insert_as_one_unit() {
|
||||
let mut session = open_plain(0x5EED);
|
||||
let region = a_clean_metric_region(&session);
|
||||
let voice = primary_voice(&session, region);
|
||||
let before = voice_events(&session, voice);
|
||||
let (_, _, origin_y) = region_staff_line(&session, region);
|
||||
|
||||
let (_, first_pos, first_dur) = before[0].clone();
|
||||
let cell = first_dur.0.mul(&RationalTime::new(1, 4).unwrap());
|
||||
let target = MusicalPosition(first_pos.0.add(&cell));
|
||||
let at = click_for_position(&session, region, &target, origin_y + 1.0);
|
||||
session
|
||||
.insert_note_at(
|
||||
at,
|
||||
&GridResolution {
|
||||
step: MusicalDuration(cell),
|
||||
},
|
||||
)
|
||||
.expect("split");
|
||||
assert_eq!(voice_events(&session, voice).len(), before.len() + 2);
|
||||
assert!(
|
||||
session.applied_operations().len() > 1,
|
||||
"the split is a multi-op transaction"
|
||||
);
|
||||
|
||||
session.undo().expect("undo");
|
||||
// Trim-head + tail-insert + new-note all undo together as one unit.
|
||||
assert_eq!(
|
||||
voice_events(&session, voice),
|
||||
before,
|
||||
"undo restores the original voice exactly"
|
||||
);
|
||||
assert_eq!(
|
||||
session.applied_operations().len(),
|
||||
0,
|
||||
"the whole unit is gone"
|
||||
);
|
||||
|
||||
session.redo().expect("redo");
|
||||
assert_eq!(
|
||||
voice_events(&session, voice).len(),
|
||||
before.len() + 2,
|
||||
"redo re-splits"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undo_restores_a_make_room_overwrite() {
|
||||
let mut session = open_plain(0x5EED);
|
||||
let region = a_clean_metric_region(&session);
|
||||
let voice = primary_voice(&session, region);
|
||||
let before = voice_events(&session, voice);
|
||||
let (first, _, _) = before[0].clone();
|
||||
let (second, _, _) = before[1].clone();
|
||||
select_first_pitch_of(&mut session, first);
|
||||
|
||||
session
|
||||
.set_selection_duration(dur(1, 2))
|
||||
.expect("lengthen with overwrite");
|
||||
assert!(
|
||||
!voice_events(&session, voice)
|
||||
.iter()
|
||||
.any(|(id, _, _)| *id == second),
|
||||
"the covered note was deleted"
|
||||
);
|
||||
session.undo().expect("undo");
|
||||
assert_eq!(
|
||||
voice_events(&session, voice),
|
||||
before,
|
||||
"undo restores both the resize and the deleted note"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undo_an_override_aware_move_restores_value_and_spelling() {
|
||||
use epiphany_core::PitchSpelling;
|
||||
use epiphany_ops::RespellPitchOp;
|
||||
|
||||
let mut session = open_rich(0x5EED);
|
||||
let selection = click_a_notehead(&mut session);
|
||||
let TypedObjectId::Pitch(pid) = selection.source else {
|
||||
panic!("a notehead selects a pitch");
|
||||
};
|
||||
// Pin a spelling so the move lands as a value + respell transaction.
|
||||
session
|
||||
.apply(OperationKind::RespellPitch(RespellPitchOp {
|
||||
pitch: pid,
|
||||
spelling: PitchSpelling::cmn(CmnNominal::D, 4),
|
||||
}))
|
||||
.expect("respell");
|
||||
let value = session.current_pitch(pid).unwrap();
|
||||
let spelling = authored_spelling(session.score(), pid).expect("pinned");
|
||||
|
||||
session
|
||||
.move_selection_staff_step(1)
|
||||
.expect("override-aware move");
|
||||
assert_ne!(session.current_pitch(pid).unwrap(), value, "value moved");
|
||||
assert_ne!(
|
||||
authored_spelling(session.score(), pid),
|
||||
Some(spelling.clone()),
|
||||
"spelling moved"
|
||||
);
|
||||
|
||||
session.undo().expect("undo the move");
|
||||
// Both transaction members (the modify and the respell) undo together.
|
||||
assert_eq!(session.current_pitch(pid).unwrap(), value, "value restored");
|
||||
assert_eq!(
|
||||
authored_spelling(session.score(), pid),
|
||||
Some(spelling),
|
||||
"spelling restored"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_new_edit_after_undo_forks_history() {
|
||||
let mut session = open_rich(0x5EED);
|
||||
assert!(!session.can_undo() && !session.can_redo());
|
||||
assert!(session.undo().is_none(), "nothing to undo");
|
||||
assert!(session.redo().is_none(), "nothing to redo");
|
||||
|
||||
click_a_notehead(&mut session);
|
||||
session.transpose_selection(1).expect("e1");
|
||||
session.transpose_selection(1).expect("e2");
|
||||
assert_eq!(session.applied_operations().len(), 2);
|
||||
|
||||
session.undo().expect("undo e2");
|
||||
assert!(session.can_redo());
|
||||
// A new edit past an undo clears the redo stack.
|
||||
session.transpose_selection(-1).expect("e3");
|
||||
assert!(!session.can_redo(), "the new edit forked away the redo");
|
||||
assert_eq!(
|
||||
session.applied_operations().len(),
|
||||
2,
|
||||
"e1 + e3 (e2 was undone and forked away)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_fork_mints_a_fresh_operation_id_not_the_undone_one() {
|
||||
let mut session = open_rich(0x5EED);
|
||||
click_a_notehead(&mut session);
|
||||
session.transpose_selection(1).expect("A");
|
||||
let a_id = session.last_applied().unwrap().id;
|
||||
session.undo().expect("undo A");
|
||||
session.transpose_selection(-1).expect("B (forks A)");
|
||||
let b_id = session.last_applied().unwrap().id;
|
||||
|
||||
assert_ne!(a_id, b_id, "B gets a fresh op id, not A's reused counter");
|
||||
// B is in the active prefix; A is forked out of it but kept in authored history.
|
||||
assert!(session.applied_operations().iter().any(|e| e.id == b_id));
|
||||
assert!(!session.applied_operations().iter().any(|e| e.id == a_id));
|
||||
assert!(session.authored_operations().iter().any(|e| e.id == a_id));
|
||||
assert_eq!(
|
||||
session.authored_operations().len(),
|
||||
2,
|
||||
"A and B both authored"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_fork_does_not_reuse_minted_event_ids() {
|
||||
let mut session = open_plain(0x5EED);
|
||||
let region = a_clean_metric_region(&session);
|
||||
let voice = primary_voice(&session, region);
|
||||
let (_, _, origin_y) = region_staff_line(&session, region);
|
||||
let opened: std::collections::BTreeSet<EventId> =
|
||||
session.score().events.iter().map(|e| e.id()).collect();
|
||||
|
||||
// Insert into empty space (mints a fresh event), then undo it.
|
||||
let last = voice_events(&session, voice).last().unwrap().clone();
|
||||
let target = MusicalPosition(last.1 .0.add(&last.2 .0));
|
||||
let at = click_for_position(&session, region, &target, origin_y + 1.0);
|
||||
let grid = GridResolution {
|
||||
step: last.2.clone(),
|
||||
};
|
||||
session.insert_note_at(at, &grid).expect("insert 1");
|
||||
let inserted = |s: &EditorSession| -> EventId {
|
||||
*s.score()
|
||||
.events
|
||||
.iter()
|
||||
.map(|e| e.id())
|
||||
.collect::<std::collections::BTreeSet<_>>()
|
||||
.difference(&opened)
|
||||
.next()
|
||||
.expect("one inserted event")
|
||||
};
|
||||
let first = inserted(&session);
|
||||
session.undo().expect("undo insert 1");
|
||||
|
||||
// Insert again at the same spot: the undone event's id left the score and the
|
||||
// active prefix, but it is still in authored history, so a fresh id is minted.
|
||||
session.insert_note_at(at, &grid).expect("insert 2");
|
||||
let second = inserted(&session);
|
||||
assert_ne!(
|
||||
second, first,
|
||||
"the second insert mints a fresh event id, not the undone one's"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edits_after_a_fork_re_reduce_cleanly() {
|
||||
let mut session = open_rich(0x5EED);
|
||||
click_a_notehead(&mut session);
|
||||
session.transpose_selection(1).expect("A");
|
||||
session.transpose_selection(1).expect("B");
|
||||
session.undo().expect("undo B");
|
||||
// Each edit re-reduces the whole active prefix; if a forked op were stranded
|
||||
// pending (a missing-predecessor hole left by a non-contiguous context), the
|
||||
// reduction would be unclean and the edit would be refused. So these succeeding
|
||||
// is the proof the fork's causal contexts are correct.
|
||||
session.transpose_selection(-1).expect("C");
|
||||
session.transpose_selection(-1).expect("D");
|
||||
|
||||
let active = session.applied_operations();
|
||||
assert_eq!(active.len(), 3, "A, C, D active; B forked away");
|
||||
let ids: std::collections::BTreeSet<_> = active.iter().map(|e| e.id).collect();
|
||||
assert_eq!(
|
||||
ids.len(),
|
||||
3,
|
||||
"distinct, monotonic op ids (B's was not reused)"
|
||||
);
|
||||
assert_eq!(
|
||||
session.authored_operations().len(),
|
||||
4,
|
||||
"A, B, C, D all authored"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "with_identity must be set before any edit")]
|
||||
fn with_identity_is_refused_after_an_undone_edit() {
|
||||
let mut session = open_rich(0x5EED);
|
||||
click_a_notehead(&mut session);
|
||||
session.transpose_selection(1).expect("edit");
|
||||
session.undo().expect("undo");
|
||||
assert!(
|
||||
session.applied_operations().is_empty(),
|
||||
"the active prefix is empty"
|
||||
);
|
||||
// ...but the session has authored history, so the identity is still fixed.
|
||||
let _ = session.with_identity(ReplicaId(2), AuthorId(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_local_edit_causally_covers_the_prior_one() {
|
||||
// The session's edits form one replica's contiguous, zero-based history, so
|
||||
|
|
|
|||
Loading…
Reference in New Issue