From dbbb41992ebded96c6c1b06bbff1259b520fcec8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonatan=20K=C5=82osko?= Date: Wed, 20 Jan 2021 02:24:58 +0100 Subject: [PATCH] Implement delta on the server, use compact representation when transmitting changes --- assets/js/editor/editor_client.js | 14 +- assets/js/editor/hook_server_adapter.js | 6 +- assets/js/editor/monaco_editor_adapter.js | 8 +- assets/js/lib/delta.js | 52 +++- assets/test/lib/delta.test.js | 8 +- lib/live_book/delta.ex | 107 ++++++++ lib/live_book/delta/iterator.ex | 60 +++++ lib/live_book/delta/operation.ex | 42 +++ lib/live_book/delta/transformation.ex | 135 ++++++++++ lib/live_book/delta_utils.ex | 34 --- lib/live_book/session.ex | 4 +- lib/live_book/session/data.ex | 12 +- lib/live_book_web/live/session_live.ex | 6 +- test/live_book/delta/transformation_test.exs | 260 +++++++++++++++++++ 14 files changed, 678 insertions(+), 70 deletions(-) create mode 100644 lib/live_book/delta.ex create mode 100644 lib/live_book/delta/iterator.ex create mode 100644 lib/live_book/delta/operation.ex create mode 100644 lib/live_book/delta/transformation.ex delete mode 100644 lib/live_book/delta_utils.ex create mode 100644 test/live_book/delta/transformation_test.exs diff --git a/assets/js/editor/editor_client.js b/assets/js/editor/editor_client.js index 63e7c0fa2..26d9105e7 100644 --- a/assets/js/editor/editor_client.js +++ b/assets/js/editor/editor_client.js @@ -120,12 +120,16 @@ class AwaitingWithBuffer { onServerDelta(delta) { // We consider the incoming delta to happen first // (because that's the case from the server's perspective). - const deltaPrime = this.awaitedDelta.compose(this.buffer).transform(delta, "right"); - this.client.applyDelta(deltaPrime); + + // Delta transformed against awaitedDelta + const deltaPrime = this.awaitedDelta.transform(delta, "right"); + // Delta transformed against both awaitedDelta and the buffer (appropriate for applying to the editor) + const deltaBis = this.buffer.transform(deltaPrime, "right"); + + this.client.applyDelta(deltaBis); + const awaitedDeltaPrime = delta.transform(this.awaitedDelta, "left"); - const bufferPrime = this.awaitedDelta - .transform(delta, "right") - .transform(this.buffer, "left"); + const bufferPrime = deltaPrime.transform(this.buffer, "left"); return new AwaitingWithBuffer(this.client, awaitedDeltaPrime, bufferPrime); } diff --git a/assets/js/editor/hook_server_adapter.js b/assets/js/editor/hook_server_adapter.js index 4e68c1eda..c1695f878 100644 --- a/assets/js/editor/hook_server_adapter.js +++ b/assets/js/editor/hook_server_adapter.js @@ -12,8 +12,8 @@ export default class HookServerAdapter { this._onDelta = null; this._onAcknowledgement = null; - this.hook.handleEvent(`cell_delta:${this.cellId}`, (delta) => { - this._onDelta && this._onDelta(new Delta(delta.ops)); + this.hook.handleEvent(`cell_delta:${this.cellId}`, ({ delta }) => { + this._onDelta && this._onDelta(Delta.fromCompressed(delta)); }); this.hook.handleEvent(`cell_acknowledgement:${this.cellId}`, () => { @@ -41,7 +41,7 @@ export default class HookServerAdapter { sendDelta(delta, revision) { this.hook.pushEvent("cell_delta", { cell_id: this.cellId, - delta, + delta: delta.toCompressed(), revision, }); } diff --git a/assets/js/editor/monaco_editor_adapter.js b/assets/js/editor/monaco_editor_adapter.js index ec390e6be..cbaf33d2a 100644 --- a/assets/js/editor/monaco_editor_adapter.js +++ b/assets/js/editor/monaco_editor_adapter.js @@ -1,5 +1,5 @@ import monaco from "./monaco"; -import Delta from "../lib/delta"; +import Delta, { isDelete, isInsert, isRetain } from "../lib/delta"; /** * Encapsulates logic related to getting/applying changes to the editor. @@ -69,11 +69,11 @@ export default class MonacoEditorAdapter { let index = 0; delta.ops.forEach((op) => { - if (typeof op.retain === "number") { + if (isRetain(op)) { index += op.retain; } - if (typeof op.insert === "string") { + if (isInsert(op)) { const start = model.getPositionAt(index); operations.push({ @@ -88,7 +88,7 @@ export default class MonacoEditorAdapter { }); } - if (typeof op.delete === "number") { + if (isDelete(op)) { const start = model.getPositionAt(index); const end = model.getPositionAt(index + op.delete); diff --git a/assets/js/lib/delta.js b/assets/js/lib/delta.js index ddf6da3df..637aa3715 100644 --- a/assets/js/lib/delta.js +++ b/assets/js/lib/delta.js @@ -32,7 +32,7 @@ export default class Delta { return this.append({ retain: length }); } - /** + /** * Appends an insert operation. */ insert(text) { @@ -144,9 +144,10 @@ export default class Delta { * so that does effectively what the original delta B meant to do. * In our case that would be `A.transform(B, "right")`. * - * Transformation should work both ways satisfying the property (assuming B is considered to happen first): + * Transformation should work both ways satisfying the property: + * (assuming B is considered to have happened first) * - * `A.concat(A.transform(B, "right")) = B.concat(B.transform(A, "left"))` + * `A.compose(A.transform(B, "right")) = B.compose(B.transform(A, "left"))` * * In Git analogy this can be thought of as rebasing branch B onto branch A. * @@ -155,7 +156,9 @@ export default class Delta { */ transform(other, priority) { if (priority !== "left" && priority !== "right") { - throw new Error(`Invalid priority "${priority}", should be either "left" or "right"`) + throw new Error( + `Invalid priority "${priority}", should be either "left" or "right"` + ); } const thisIter = new Iterator(this.ops); @@ -163,7 +166,10 @@ export default class Delta { const delta = new Delta(); while (thisIter.hasNext() || otherIter.hasNext()) { - if (isInsert(thisIter.peek()) && (!isInsert(otherIter.peek()) || priority === "left")) { + if ( + isInsert(thisIter.peek()) && + (!isInsert(otherIter.peek()) || priority === "left") + ) { const insertLength = operationLength(thisIter.next()); delta.retain(insertLength); } else if (isInsert(otherIter.peek())) { @@ -195,6 +201,34 @@ export default class Delta { return this; } + + toCompressed() { + return this.ops.map((op) => { + if (isInsert(op)) { + return op.insert; + } else if (isRetain(op)) { + return op.retain; + } else if (isDelete(op)) { + return -op.delete; + } + + throw new Error(`Invalid operation ${op}`); + }); + } + + static fromCompressed(list) { + return list.reduce((delta, compressedOp) => { + if (typeof compressedOp === "string") { + return delta.insert(compressedOp); + } else if (typeof compressedOp === "number" && compressedOp >= 0) { + return delta.retain(compressedOp); + } else if (typeof compressedOp === "number" && compressedOp < 0) { + return delta.delete(-compressedOp); + } + + throw new Error(`Invalid compressed operation ${compressedOp}`); + }, new this()); + } } /** @@ -236,7 +270,7 @@ class Iterator { return { insert: nextOp.insert.substr(offset, length) }; } } else { - return { retain: Infinity }; + return { retain: length }; } } @@ -267,14 +301,14 @@ function operationLength(op) { } } -function isInsert(op) { +export function isInsert(op) { return typeof op.insert === "string"; } -function isRetain(op) { +export function isRetain(op) { return typeof op.retain === "number"; } -function isDelete(op) { +export function isDelete(op) { return typeof op.delete === "number"; } diff --git a/assets/test/lib/delta.test.js b/assets/test/lib/delta.test.js index 1cdd9a36e..76d698795 100644 --- a/assets/test/lib/delta.test.js +++ b/assets/test/lib/delta.test.js @@ -165,7 +165,7 @@ describe('Delta', () => { }); it('insert against retain', () => { - const a = new Delta().retain(1); + const a = new Delta().retain(1).insert('A'); const b = new Delta().insert('B'); const bPrime = new Delta().insert('B'); expect(a.transform(b, "right")).toEqual(bPrime); @@ -211,7 +211,7 @@ describe('Delta', () => { .delete(1); expect(a.transform(b, "right")).toEqual(bPrimeAssumingBFirst); - expect(b.transform(a, "right")).toEqual(aPrimeAssumingBFirst); + expect(b.transform(a, "left")).toEqual(aPrimeAssumingBFirst); }); it('conflicting appends', () => { @@ -229,7 +229,7 @@ describe('Delta', () => { const bPrime = new Delta().retain(5).insert('bb'); const aPrime = new Delta().insert('aa'); expect(a.transform(b, "right")).toEqual(bPrime); - expect(b.transform(a, "right")).toEqual(aPrime); + expect(b.transform(a, "left")).toEqual(aPrime); }); it('trailing deletes with differing lengths', () => { @@ -238,7 +238,7 @@ describe('Delta', () => { const bPrime = new Delta().delete(2); const aPrime = new Delta(); expect(a.transform(b, "right")).toEqual(bPrime); - expect(b.transform(a, "right")).toEqual(aPrime); + expect(b.transform(a, "left")).toEqual(aPrime); }); it('immutability', () => { diff --git a/lib/live_book/delta.ex b/lib/live_book/delta.ex new file mode 100644 index 000000000..8bbc055c8 --- /dev/null +++ b/lib/live_book/delta.ex @@ -0,0 +1,107 @@ +defmodule LiveBook.Delta do + defstruct ops: [] + + alias LiveBook.Delta.{Operation, Transformation} + + @type t :: %__MODULE__{ + ops: list(Operation.t()) + } + + @doc """ + Creates a new delta, optionally taking a list of operations. + """ + def new(opts \\ []) + def new([]), do: %__MODULE__{} + def new(ops), do: Enum.reduce(ops, new(), &append(&2, &1)) + + def insert(delta, text) do + append(delta, Operation.insert(text)) + end + + def retain(delta, length) do + append(delta, Operation.retain(length)) + end + + def delete(delta, length) do + append(delta, Operation.delete(length)) + end + + def append(delta, op) do + Map.update!(delta, :ops, fn ops -> + ops + |> Enum.reverse() + |> compact(op) + |> Enum.reverse() + end) + end + + defp compact(ops, {:insert, ""}), do: ops + defp compact(ops, {:retain, 0}), do: ops + defp compact(ops, {:delete, 0}), do: ops + defp compact([], new_op), do: [new_op] + + defp compact([{:delete, _} = del | ops_remainder], {:insert, _} = ins) do + ops_remainder + |> compact(ins) + |> compact(del) + end + + defp compact([{:retain, len_a} | ops_remainder], {:retain, len_b}) do + [Operation.retain(len_a + len_b) | ops_remainder] + end + + defp compact([{:insert, text_a} | ops_remainder], {:insert, text_b}) do + [Operation.insert(text_a <> text_b) | ops_remainder] + end + + defp compact([{:delete, len_a} | ops_remainder], {:delete, len_b}) do + [Operation.delete(len_a + len_b) | ops_remainder] + end + + defp compact(ops, new_op), do: [new_op | ops] + + def trim(%TextDelta{ops: []} = delta), do: delta + + def trim(delta) do + case List.last(delta.ops) do + {:retain, _} -> + Map.update!(delta, :ops, fn ops -> + ops |> Enum.reverse() |> tl() |> Enum.reverse() + end) + + _ -> + delta + end + end + + def apply_to_text(delta, text) do + apply_ops_to_text(delta.ops, text) + end + + defp apply_ops_to_text([], text), do: text + + defp apply_ops_to_text([{:retain, n} | ops], text) do + {left, right} = String.split_at(text, n) + left <> apply_ops_to_text(ops, right) + end + + defp apply_ops_to_text([{:insert, inserted} | ops], text) do + inserted <> apply_ops_to_text(ops, text) + end + + defp apply_ops_to_text([{:delete, n} | ops], text) do + apply_ops_to_text(ops, String.slice(text, n..-1)) + end + + def to_compressed(delta) do + Enum.map(delta.ops, &Operation.to_compressed/1) + end + + def from_compressed(list) do + list + |> Enum.map(&Operation.from_compressed/1) + |> new() + end + + defdelegate transform(left, right, priority), to: Transformation +end diff --git a/lib/live_book/delta/iterator.ex b/lib/live_book/delta/iterator.ex new file mode 100644 index 000000000..37d3da903 --- /dev/null +++ b/lib/live_book/delta/iterator.ex @@ -0,0 +1,60 @@ +defmodule LiveBook.Delta.Iterator do + alias LiveBook.Delta.Operation + + @typedoc """ + Individual set of operations. + """ + @type set :: [Operation.t()] + + @typedoc """ + Two sets of operations to iterate. + """ + @type sets :: {set, set} + + @typedoc """ + A tuple representing the new head and tail operations of the two operation + sets being iterated over. + """ + @type cycle :: {set_split, set_split} + + @typedoc """ + A set's next scanned full or partial operation, and its resulting tail set. + """ + @type set_split :: {Operation.t() | nil, set} + + @doc """ + Generates next cycle by iterating over given sets of operations. + """ + @spec next(sets) :: cycle + def next(sets) + + def next({[], []}) do + {{nil, []}, {nil, []}} + end + + def next({[], [head_b | tail_b]}) do + {{nil, []}, {head_b, tail_b}} + end + + def next({[head_a | tail_a], []}) do + {{head_a, tail_a}, {nil, []}} + end + + def next({[head_a | tail_a], [head_b | tail_b]}) do + len_a = Operation.length(head_a) + len_b = Operation.length(head_b) + + cond do + len_a > len_b -> + {head_a, remainder_a} = Operation.slice(head_a, len_b) + {{head_a, [remainder_a | tail_a]}, {head_b, tail_b}} + + len_a < len_b -> + {head_b, remainder_b} = Operation.slice(head_b, len_a) + {{head_a, tail_a}, {head_b, [remainder_b | tail_b]}} + + true -> + {{head_a, tail_a}, {head_b, tail_b}} + end + end +end diff --git a/lib/live_book/delta/operation.ex b/lib/live_book/delta/operation.ex new file mode 100644 index 000000000..be8d239c0 --- /dev/null +++ b/lib/live_book/delta/operation.ex @@ -0,0 +1,42 @@ +defmodule LiveBook.Delta.Operation do + @type t :: insert | retain | delete + + @type insert :: {:insert, String.t()} + @type retain :: {:retain, non_neg_integer()} + @type delete :: {:delete, non_neg_integer()} + + def insert(text), do: {:insert, text} + + def retain(length), do: {:retain, length} + + def delete(length), do: {:delete, length} + + def type({type, _}), do: type + + def length({:insert, text}), do: String.length(text) + def length({:retain, length}), do: length + def length({:delete, length}), do: length + + def slice(op, idx) + + def slice({:insert, text}, idx) do + {part_one, part_two} = String.split_at(text, idx) + {insert(part_one), insert(part_two)} + end + + def slice({:retain, length}, idx) do + {retain(idx), retain(length - idx)} + end + + def slice({:delete, length}, idx) do + {delete(idx), delete(length - idx)} + end + + def to_compressed({:insert, text}), do: text + def to_compressed({:retain, length}), do: length + def to_compressed({:delete, length}), do: -length + + def from_compressed(text) when is_binary(text), do: {:insert, text} + def from_compressed(length) when is_integer(length) and length >= 0, do: {:retain, length} + def from_compressed(length) when is_integer(length) and length < 0, do: {:delete, -length} +end diff --git a/lib/live_book/delta/transformation.ex b/lib/live_book/delta/transformation.ex new file mode 100644 index 000000000..cdc0f4168 --- /dev/null +++ b/lib/live_book/delta/transformation.ex @@ -0,0 +1,135 @@ +defmodule LiveBook.Delta.Transformation do + alias LiveBook.Delta + alias LiveBook.Delta.{Operation, Iterator} + + @type priority :: :left | :right + + def transform(left, right, priority) do + {left.ops, right.ops} + |> Iterator.next() + |> do_transform(priority, Delta.new()) + |> Delta.trim() + end + + defp do_transform({{_, _}, {nil, _}}, _, result) do + result + end + + defp do_transform({{nil, _}, {op_b, remainder_b}}, _, result) do + Enum.reduce([op_b | remainder_b], result, &Delta.append(&2, &1)) + end + + defp do_transform( + {{{:insert, _} = ins_a, remainder_a}, {{:insert, _} = ins_b, remainder_b}}, + :left, + result + ) do + retain = make_retain(ins_a) + + {remainder_a, [ins_b | remainder_b]} + |> Iterator.next() + |> do_transform(:left, Delta.append(result, retain)) + end + + defp do_transform( + {{{:insert, _} = ins_a, remainder_a}, {{:insert, _} = ins_b, remainder_b}}, + :right, + result + ) do + {[ins_a | remainder_a], remainder_b} + |> Iterator.next() + |> do_transform(:right, Delta.append(result, ins_b)) + end + + defp do_transform( + {{{:insert, _} = ins, remainder_a}, {{:retain, _} = ret, remainder_b}}, + priority, + result + ) do + retain = make_retain(ins) + + {remainder_a, [ret | remainder_b]} + |> Iterator.next() + |> do_transform(priority, Delta.append(result, retain)) + end + + defp do_transform( + {{{:insert, _} = ins, remainder_a}, {{:delete, _} = del, remainder_b}}, + priority, + result + ) do + retain = make_retain(ins) + + {remainder_a, [del | remainder_b]} + |> Iterator.next() + |> do_transform(priority, Delta.append(result, retain)) + end + + defp do_transform( + {{{:delete, _} = del, remainder_a}, {{:insert, _} = ins, remainder_b}}, + priority, + result + ) do + {[del | remainder_a], remainder_b} + |> Iterator.next() + |> do_transform(priority, Delta.append(result, ins)) + end + + defp do_transform( + {{{:delete, _}, remainder_a}, {{:retain, _}, remainder_b}}, + priority, + result + ) do + {remainder_a, remainder_b} + |> Iterator.next() + |> do_transform(priority, result) + end + + defp do_transform( + {{{:delete, _}, remainder_a}, {{:delete, _}, remainder_b}}, + priority, + result + ) do + {remainder_a, remainder_b} + |> Iterator.next() + |> do_transform(priority, result) + end + + defp do_transform( + {{{:retain, _} = ret, remainder_a}, {{:insert, _} = ins, remainder_b}}, + priority, + result + ) do + {[ret | remainder_a], remainder_b} + |> Iterator.next() + |> do_transform(priority, Delta.append(result, ins)) + end + + defp do_transform( + {{{:retain, _} = ret_a, remainder_a}, {{:retain, _}, remainder_b}}, + priority, + result + ) do + retain = make_retain(ret_a) + + {remainder_a, remainder_b} + |> Iterator.next() + |> do_transform(priority, Delta.append(result, retain)) + end + + defp do_transform( + {{{:retain, _}, remainder_a}, {{:delete, _} = del, remainder_b}}, + priority, + result + ) do + {remainder_a, remainder_b} + |> Iterator.next() + |> do_transform(priority, Delta.append(result, del)) + end + + defp make_retain(op) do + op + |> Operation.length() + |> Operation.retain() + end +end diff --git a/lib/live_book/delta_utils.ex b/lib/live_book/delta_utils.ex deleted file mode 100644 index c1e509ac9..000000000 --- a/lib/live_book/delta_utils.ex +++ /dev/null @@ -1,34 +0,0 @@ -defmodule LiveBook.DeltaUtils do - def apply_delta_to_text(text, delta) do - apply_operations_to_text(text, delta.ops) - end - - defp apply_operations_to_text(text, []), do: text - - defp apply_operations_to_text(text, [%{retain: n} | ops]) do - {left, right} = String.split_at(text, n) - left <> apply_operations_to_text(right, ops) - end - - defp apply_operations_to_text(text, [%{insert: inserted} | ops]) do - inserted <> apply_operations_to_text(text, ops) - end - - defp apply_operations_to_text(text, [%{delete: n} | ops]) do - apply_operations_to_text(String.slice(text, n..-1), ops) - end - - def delta_to_map(delta) do - %{ops: delta.ops} - end - - def delta_from_map(%{"ops" => ops}) do - ops - |> Enum.map(&parse_operation/1) - |> TextDelta.new() - end - - defp parse_operation(%{"insert" => insert}), do: %{insert: insert} - defp parse_operation(%{"retain" => retain}), do: %{retain: retain} - defp parse_operation(%{"delete" => delete}), do: %{delete: delete} -end diff --git a/lib/live_book/session.ex b/lib/live_book/session.ex index 13bcb2244..9eddcf0b4 100644 --- a/lib/live_book/session.ex +++ b/lib/live_book/session.ex @@ -15,7 +15,7 @@ defmodule LiveBook.Session do use GenServer, restart: :temporary alias LiveBook.Session.Data - alias LiveBook.{Evaluator, EvaluatorSupervisor, Utils, Notebook} + alias LiveBook.{Evaluator, EvaluatorSupervisor, Utils, Notebook, Delta} alias LiveBook.Notebook.{Cell, Section} @type state :: %{ @@ -135,7 +135,7 @@ defmodule LiveBook.Session do @doc """ Asynchronously sends a cell delta to apply to the server. """ - @spec apply_cell_delta(id(), pid(), Cell.id(), TextDelta.t(), Data.cell_revision()) :: :ok + @spec apply_cell_delta(id(), pid(), Cell.id(), Delta.t(), Data.cell_revision()) :: :ok def apply_cell_delta(session_id, from, cell_id, delta, revision) do GenServer.cast(name(session_id), {:apply_cell_delta, from, cell_id, delta, revision}) end diff --git a/lib/live_book/session/data.ex b/lib/live_book/session/data.ex index e8a8a52f0..568cd6842 100644 --- a/lib/live_book/session/data.ex +++ b/lib/live_book/session/data.ex @@ -23,7 +23,7 @@ defmodule LiveBook.Session.Data do :deleted_cells ] - alias LiveBook.{Notebook, Evaluator, DeltaUtils} + alias LiveBook.{Notebook, Evaluator, Delta} alias LiveBook.Notebook.{Cell, Section} @type t :: %__MODULE__{ @@ -44,7 +44,7 @@ defmodule LiveBook.Session.Data do validity_status: cell_validity_status(), evaluation_status: cell_evaluation_status(), revision: cell_revision(), - deltas: list(TextDelta.t()), + deltas: list(Delta.t()), evaluated_at: DateTime.t() } @@ -66,13 +66,13 @@ defmodule LiveBook.Session.Data do | {:cancel_cell_evaluation, Cell.id()} | {:set_notebook_name, String.t()} | {:set_section_name, Section.id(), String.t()} - | {:apply_cell_delta, pid(), Cell.id(), TextDelta.t(), cell_revision()} + | {:apply_cell_delta, pid(), Cell.id(), Delta.t(), cell_revision()} @type action :: {:start_evaluation, Cell.t(), Section.t()} | {:stop_evaluation, Section.t()} | {:forget_evaluation, Cell.t(), Section.t()} - | {:broadcast_delta, pid(), Cell.t(), TextDelta.t()} + | {:broadcast_delta, pid(), Cell.t(), Delta.t()} @doc """ Returns a fresh notebook session state. @@ -431,10 +431,10 @@ defmodule LiveBook.Session.Data do rebased_new_delta = Enum.reduce(deltas_ahead, delta, fn delta_ahead, rebased_new_delta -> - TextDelta.transform(delta_ahead, rebased_new_delta, :left) + Delta.transform(delta_ahead, rebased_new_delta, :left) end) - new_source = DeltaUtils.apply_delta_to_text(cell.source, rebased_new_delta) + new_source = Delta.apply_to_text(rebased_new_delta, cell.source) data_actions |> set!(notebook: Notebook.update_cell(data.notebook, cell.id, &%{&1 | source: new_source})) diff --git a/lib/live_book_web/live/session_live.ex b/lib/live_book_web/live/session_live.ex index f87ade2b3..6253bcdc2 100644 --- a/lib/live_book_web/live/session_live.ex +++ b/lib/live_book_web/live/session_live.ex @@ -1,7 +1,7 @@ defmodule LiveBookWeb.SessionLive do use LiveBookWeb, :live_view - alias LiveBook.{SessionSupervisor, Session, DeltaUtils} + alias LiveBook.{SessionSupervisor, Session, Delta} @impl true def mount(%{"id" => session_id}, _session, socket) do @@ -141,7 +141,7 @@ defmodule LiveBookWeb.SessionLive do %{"cell_id" => cell_id, "delta" => delta, "revision" => revision}, socket ) do - delta = DeltaUtils.delta_from_map(delta) + delta = Delta.from_compressed(delta) Session.apply_cell_delta(socket.assigns.session_id, self(), cell_id, delta, revision) {:noreply, socket} @@ -177,7 +177,7 @@ defmodule LiveBookWeb.SessionLive do if from == self() do push_event(socket, "cell_acknowledgement:#{cell.id}", %{}) else - push_event(socket, "cell_delta:#{cell.id}", DeltaUtils.delta_to_map(delta)) + push_event(socket, "cell_delta:#{cell.id}", %{delta: Delta.to_compressed(delta)}) end end diff --git a/test/live_book/delta/transformation_test.exs b/test/live_book/delta/transformation_test.exs new file mode 100644 index 000000000..4c4b7f3a4 --- /dev/null +++ b/test/live_book/delta/transformation_test.exs @@ -0,0 +1,260 @@ +defmodule LiveBook.Delta.TransformationText do + use ExUnit.Case, async: true + + alias LiveBook.Delta + + describe "transform" do + test "insert against insert" do + a = + Delta.new() + |> Delta.insert("A") + + b = + Delta.new() + |> Delta.insert("B") + + b_prime_assuming_a_a = + Delta.new() + |> Delta.retain(1) + |> Delta.insert("B") + + b_prime_assuming_b_a = + Delta.new() + |> Delta.insert("B") + + assert Delta.transform(a, b, :left) == b_prime_assuming_a_a + assert Delta.transform(a, b, :right) == b_prime_assuming_b_a + end + + test "retain against insert" do + a = + Delta.new() + |> Delta.insert("A") + + b = + Delta.new() + |> Delta.retain(1) + |> Delta.insert("B") + + b_prime = + Delta.new() + |> Delta.retain(2) + |> Delta.insert("B") + + assert Delta.transform(a, b, :right) == b_prime + end + + test "delete against insert" do + a = + Delta.new() + |> Delta.insert("A") + + b = + Delta.new() + |> Delta.delete(1) + + b_prime = + Delta.new() + |> Delta.retain(1) + |> Delta.delete(1) + + assert Delta.transform(a, b, :right) == b_prime + end + + test "insert against delete" do + a = + Delta.new() + |> Delta.delete(1) + + b = + Delta.new() + |> Delta.insert("B") + + b_prime = + Delta.new() + |> Delta.insert("B") + + assert Delta.transform(a, b, :right) == b_prime + end + + test "retain against delete" do + a = + Delta.new() + |> Delta.delete(1) + + b = + Delta.new() + |> Delta.retain(1) + + b_prime = Delta.new() + assert Delta.transform(a, b, :right) == b_prime + end + + test "delete against delete" do + a = + Delta.new() + |> Delta.delete(1) + + b = + Delta.new() + |> Delta.delete(1) + + b_prime = Delta.new() + assert Delta.transform(a, b, :right) == b_prime + end + + test "insert against retain" do + a = + Delta.new() + |> Delta.retain(1) + # Add insert, so that trailing retain is not trimmed + |> Delta.insert("A") + + b = + Delta.new() + |> Delta.insert("B") + + b_prime = + Delta.new() + |> Delta.insert("B") + + assert Delta.transform(a, b, :right) == b_prime + end + + test "retain against retain" do + a = + Delta.new() + |> Delta.retain(1) + |> Delta.insert("A") + + b = + Delta.new() + |> Delta.retain(1) + |> Delta.insert("B") + + b_prime = + Delta.new() + |> Delta.retain(1) + |> Delta.insert("B") + + assert Delta.transform(a, b, :right) == b_prime + end + + test "delete against retain" do + a = + Delta.new() + |> Delta.retain(1) + + b = + Delta.new() + |> Delta.delete(1) + + b_prime = + Delta.new() + |> Delta.delete(1) + + assert Delta.transform(a, b, :right) == b_prime + end + + test "alternating edits" do + a = + Delta.new() + |> Delta.retain(2) + |> Delta.insert("si") + |> Delta.delete(5) + + b = + Delta.new() + |> Delta.retain(1) + |> Delta.insert("e") + |> Delta.delete(5) + |> Delta.retain(1) + |> Delta.insert("ow") + + b_prime_assuming_b_first = + Delta.new() + |> Delta.retain(1) + |> Delta.insert("e") + |> Delta.delete(1) + |> Delta.retain(2) + |> Delta.insert("ow") + + a_prime_assuming_b_first = + Delta.new() + |> Delta.retain(2) + |> Delta.insert("si") + |> Delta.delete(1) + + assert Delta.transform(a, b, :right) == b_prime_assuming_b_first + assert Delta.transform(b, a, :left) == a_prime_assuming_b_first + end + + test "conflicting appends" do + a = + Delta.new() + |> Delta.retain(3) + |> Delta.insert("aa") + + b = + Delta.new() + |> Delta.retain(3) + |> Delta.insert("bb") + + b_prime_assuming_b_first = + Delta.new() + |> Delta.retain(3) + |> Delta.insert("bb") + + a_prime_assuming_b_first = + Delta.new() + |> Delta.retain(5) + |> Delta.insert("aa") + + assert Delta.transform(a, b, :right) == b_prime_assuming_b_first + assert Delta.transform(b, a, :left) == a_prime_assuming_b_first + end + + test "prepend and append" do + a = + Delta.new() + |> Delta.insert("aa") + + b = + Delta.new() + |> Delta.retain(3) + |> Delta.insert("bb") + + b_prime = + Delta.new() + |> Delta.retain(5) + |> Delta.insert("bb") + + a_prime = + Delta.new() + |> Delta.insert("aa") + + assert Delta.transform(a, b, :right) == b_prime + assert Delta.transform(b, a, :left) == a_prime + end + + test "trailing deletes with differing lengths" do + a = + Delta.new() + |> Delta.retain(2) + |> Delta.delete(1) + + b = + Delta.new() + |> Delta.delete(3) + + b_prime = + Delta.new() + |> Delta.delete(2) + + a_prime = Delta.new() + + assert Delta.transform(a, b, :right) == b_prime + assert Delta.transform(b, a, :left) == a_prime + end + end +end