mirror of
https://github.com/livebook-dev/livebook.git
synced 2025-09-07 13:34:55 +08:00
Implement delta on the server, use compact representation when transmitting changes
This commit is contained in:
parent
fa115e045b
commit
dbbb41992e
14 changed files with 678 additions and 70 deletions
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
||||
|
|
|
@ -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";
|
||||
}
|
||||
|
|
|
@ -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', () => {
|
||||
|
|
107
lib/live_book/delta.ex
Normal file
107
lib/live_book/delta.ex
Normal file
|
@ -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
|
60
lib/live_book/delta/iterator.ex
Normal file
60
lib/live_book/delta/iterator.ex
Normal file
|
@ -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
|
42
lib/live_book/delta/operation.ex
Normal file
42
lib/live_book/delta/operation.ex
Normal file
|
@ -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
|
135
lib/live_book/delta/transformation.ex
Normal file
135
lib/live_book/delta/transformation.ex
Normal file
|
@ -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
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
@ -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}))
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
260
test/live_book/delta/transformation_test.exs
Normal file
260
test/live_book/delta/transformation_test.exs
Normal file
|
@ -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
|
Loading…
Add table
Reference in a new issue