From a834e11f49b5fe616e072d43bf7f5fdfa3538db3 Mon Sep 17 00:00:00 2001 From: Cort Fritz Date: Sun, 22 Dec 2024 18:53:21 -0800 Subject: [PATCH] Feature/undo (#103) * draft first pass of undo * hack mix to get project to compile with rust * hack nif to try to get tests to pass * save WiP of undo manager * revert mix to original repo * get undo code to compile * undo manager compiling checkpoint * prove passable tests with incremental strategy * add undo * save WiP of undo with origin * fix NIF param context * test undo * use yrs undo correctly * extend capability beyond Yex.Text to Yex.Map and Yex.Array * make undo threadsafe * add origin awareness tests to map and array * resolved, apparently, thread safety by changing test setup * ensure thread safety * refactor to use shared implementation for new * unify NIF interface * implement stop_capture, expand_scope, and exclude_origin * clean up obviated parallel "new" functions * add observers and ability to add/get metadata from stack items * manage undo observer state in GenServer * remove debug * add clear * add undo manager with options * add options timeout test * mirror yjs examples for clarity for satoren * Update lib/server/undo_server_observer_behaviour.ex Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * move server tests to correct test folder * refactor undo_server as simplified observer_server * remove bad undo observer scheme * draft first pass of undo * hack mix to get project to compile with rust * hack nif to try to get tests to pass * save WiP of undo manager * revert mix to original repo * get undo code to compile * undo manager compiling checkpoint * prove passable tests with incremental strategy * add undo * save WiP of undo with origin * fix NIF param context * test undo * use yrs undo correctly * extend capability beyond Yex.Text to Yex.Map and Yex.Array * make undo threadsafe * add origin awareness tests to map and array * resolved, apparently, thread safety by changing test setup * ensure thread safety * refactor to use shared implementation for new * unify NIF interface * implement stop_capture, expand_scope, and exclude_origin * clean up obviated parallel "new" functions * add observers and ability to add/get metadata from stack items * manage undo observer state in GenServer * remove debug * add clear * add undo manager with options * add options timeout test * mirror yjs examples for clarity for satoren * Update lib/server/undo_server_observer_behaviour.ex Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * move server tests to correct test folder * refactor undo_server as simplified observer_server * remove bad undo observer scheme * put undo in correct fmt position * remove unused atoms previously supporting undo observers * fmt undo * format elixir code * format changes to nif * adopt byzantine structure to try to help coveralls see we are indeed covered * format undo test * worsen code structure to put up with coveralls not seeing inside pattern matches * test embedded objects * format * remove pattern matching * improve order * add xml tests * use NifUntaggedEnum * fmt properly * suffice coderabbit refactor suggestion Refactor suggestion Ensure consistent return types for new/2 and new_with_options/3. Currently, new_with_options/3 uses unwrap_manager_result/1 to directly return either the manager struct or an error, whereas new/2 calls new_with_options/3 without wrapping the result. This can lead to inconsistent error handling and unexpected behaviors. For clarity and better error handling, consider modifying both functions to return {:ok, manager} on success or {:error, reason} on failure. This aligns with Elixir conventions and makes error handling more predictable for the caller. Apply this diff to adjust the return values: def new(doc, scope) when is_struct(scope) do - new_with_options(doc, scope, %Options{}) + case new_with_options(doc, scope, %Options{}) do + {:ok, manager} -> {:ok, manager} + error -> error + end end def new_with_options(doc, scope, %Options{} = options) do doc |> Yex.Nif.undo_manager_new_with_options(scope, options) - |> unwrap_manager_result() + |> case do + {:ok, manager} -> {:ok, manager} + error -> error + end end * suffice coderabbit potential issue Handle potential errors when creating an UndoManager. In the create_undo_manager_with_options function, the use of unwrap() can cause a panic if an error occurs while getting the branch reference. It is safer to handle the error properly to prevent unexpected crashes. * suffice coderabbit unsafe transmute usage issue Avoid unsafe transmute usage and improper Env lifetime extension. Using unsafe { std::mem::transmute(env) } to extend the lifetime of Env to static is unsafe and can lead to undefined behavior. The Env should not outlive the NIF call. * remove unused functions * make purpose more clear * make purpose more clear * remove test replaced by guards * fix nits * fmt * improve coverage * simplify * satisfy coveralls demand for coverage * improve comments * replace try with case pattern match for better style * remove unsued crate * remove unsued atoms * clarify comments in tests * clarify tests * clarify purpose of origins in test --------- Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- .tool-versions | 1 + lib/nif.ex | 13 + lib/undo_manager.ex | 141 +++++ mix.exs | 3 +- mix.lock | 2 + native/yex/src/atoms.rs | 1 + native/yex/src/lib.rs | 1 + native/yex/src/shared_type.rs | 1 - native/yex/src/undo.rs | 289 +++++++++ test/undo_manager_test.exs | 1108 +++++++++++++++++++++++++++++++++ 10 files changed, 1558 insertions(+), 2 deletions(-) create mode 100644 .tool-versions create mode 100644 lib/undo_manager.ex create mode 100644 native/yex/src/undo.rs create mode 100644 test/undo_manager_test.exs diff --git a/.tool-versions b/.tool-versions new file mode 100644 index 0000000..8fb30ab --- /dev/null +++ b/.tool-versions @@ -0,0 +1 @@ +elixir 1.17.3-otp-27 diff --git a/lib/nif.ex b/lib/nif.ex index 72ab0c4..38c9b88 100644 --- a/lib/nif.ex +++ b/lib/nif.ex @@ -167,6 +167,19 @@ defmodule Yex.Nif do do: :erlang.nif_error(:nif_not_loaded) def awareness_remove_states(_awareness, _clients), do: :erlang.nif_error(:nif_not_loaded) + + def undo_manager_new(_doc, _scope), do: :erlang.nif_error(:nif_not_loaded) + + def undo_manager_new_with_options(_doc, _scope, _options), + do: :erlang.nif_error(:nif_not_loaded) + + def undo_manager_include_origin(_undo_manager, _origin), do: :erlang.nif_error(:nif_not_loaded) + def undo_manager_undo(_undo_manager), do: :erlang.nif_error(:nif_not_loaded) + def undo_manager_redo(_undo_manager), do: :erlang.nif_error(:nif_not_loaded) + def undo_manager_expand_scope(_undo_manager, _scope), do: :erlang.nif_error(:nif_not_loaded) + def undo_manager_exclude_origin(_undo_manager, _origin), do: :erlang.nif_error(:nif_not_loaded) + def undo_manager_stop_capturing(_undo_manager), do: :erlang.nif_error(:nif_not_loaded) + def undo_manager_clear(_undo_manager), do: :erlang.nif_error(:nif_not_loaded) end defmodule Yex.Nif.Util do diff --git a/lib/undo_manager.ex b/lib/undo_manager.ex new file mode 100644 index 0000000..6a1fa8f --- /dev/null +++ b/lib/undo_manager.ex @@ -0,0 +1,141 @@ +defmodule Yex.UndoManager.Options do + @moduledoc """ + Options for creating an UndoManager. + + * `:capture_timeout` - Time in milliseconds to wait before creating a new capture group + """ + # Default from Yrs + defstruct capture_timeout: 500 + + @type t :: %__MODULE__{ + capture_timeout: non_neg_integer() + } +end + +defmodule Yex.UndoManager do + alias Yex.UndoManager.Options + + defguard is_valid_scope(scope) + when is_struct(scope, Yex.Text) or + is_struct(scope, Yex.Array) or + is_struct(scope, Yex.Map) or + is_struct(scope, Yex.XmlText) or + is_struct(scope, Yex.XmlElement) or + is_struct(scope, Yex.XmlFragment) + + @moduledoc """ + Represents a Y.UndoManager instance. + """ + defstruct [:reference] + + @type t :: %__MODULE__{ + reference: reference() + } + + @doc """ + Creates a new UndoManager for the given document and scope with default options. + The scope can be a Text, Array, Map, XmlText, XmlElement, or XmlFragment type. + + ## Errors + - Returns `{:error, "Invalid scope: expected a struct"}` if scope is not a struct + - Returns `{:error, "Failed to get branch reference"}` if there's an error accessing the scope + """ + @spec new(Yex.Doc.t(), struct()) :: + {:ok, Yex.UndoManager.t()} | {:error, term()} + def new(doc, scope) + when is_valid_scope(scope) do + new_with_options(doc, scope, %Options{}) + end + + @doc """ + Creates a new UndoManager with the given options. + + ## Options + + See `Yex.UndoManager.Options` for available options. + + ## Errors + - Returns `{:error, "NIF error: "}` if underlying NIF returns an error + """ + @spec new_with_options(Yex.Doc.t(), struct(), Options.t()) :: + {:ok, Yex.UndoManager.t()} | {:error, term()} + def new_with_options(doc, scope, options) + when is_valid_scope(scope) and + is_struct(options, Options) do + case Yex.Nif.undo_manager_new_with_options(doc, scope, options) do + {:ok, manager} -> {:ok, manager} + {:error, message} -> {:error, "NIF error: #{message}"} + end + end + + @doc """ + Includes an origin to be tracked by the UndoManager. + """ + def include_origin(undo_manager, origin) do + Yex.Nif.undo_manager_include_origin(undo_manager, origin) + end + + @doc """ + Excludes an origin from being tracked by the UndoManager. + """ + def exclude_origin(undo_manager, origin) do + Yex.Nif.undo_manager_exclude_origin(undo_manager, origin) + end + + @doc """ + Undoes the last tracked change. + """ + def undo(undo_manager) do + Yex.Nif.undo_manager_undo(undo_manager) + end + + @doc """ + Redoes the last undone change. + """ + def redo(undo_manager) do + Yex.Nif.undo_manager_redo(undo_manager) + end + + @doc """ + Expands the scope of the UndoManager to include additional shared types. + The scope can be a Text, Array, or Map type. + """ + def expand_scope(undo_manager, scope) do + Yex.Nif.undo_manager_expand_scope(undo_manager, scope) + end + + @doc """ + Stops capturing changes for the current stack item. + This ensures that the next change will create a new stack item instead of + being merged with the previous one, even if it occurs within the normal timeout window. + + ## Example: + text = Doc.get_text(doc, "text") + undo_manager = UndoManager.new(doc, text) + + Text.insert(text, 0, "a") + UndoManager.stop_capturing(undo_manager) + Text.insert(text, 1, "b") + UndoManager.undo(undo_manager) + # Text.to_string(text) will be "a" (only "b" was removed) + """ + def stop_capturing(undo_manager) do + Yex.Nif.undo_manager_stop_capturing(undo_manager) + end + + @doc """ + Clears all StackItems stored within current UndoManager, effectively resetting its state. + + ## Example: + text = Doc.get_text(doc, "text") + undo_manager = UndoManager.new(doc, text) + + Text.insert(text, 0, "Hello") + Text.insert(text, 5, " World") + UndoManager.clear(undo_manager) + # All undo/redo history is now cleared + """ + def clear(undo_manager) do + Yex.Nif.undo_manager_clear(undo_manager) + end +end diff --git a/mix.exs b/mix.exs index 32dea35..3938251 100644 --- a/mix.exs +++ b/mix.exs @@ -64,7 +64,8 @@ defmodule Yex.MixProject do {:credo, "~> 1.7", only: [:dev, :test], runtime: false}, {:dialyxir, "~> 1.4", only: [:dev, :test], runtime: false}, {:excoveralls, "~> 0.18", only: :test}, - {:benchee, "~> 1.0", only: :dev} + {:benchee, "~> 1.0", only: :dev}, + {:mock, "~> 0.3.0", only: :test} ] end end diff --git a/mix.lock b/mix.lock index a50fab0..3eb61e7 100644 --- a/mix.lock +++ b/mix.lock @@ -16,8 +16,10 @@ "makeup": {:hex, :makeup, "1.1.2", "9ba8837913bdf757787e71c1581c21f9d2455f4dd04cfca785c70bbfff1a76a3", [:mix], [{:nimble_parsec, "~> 1.2.2 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "cce1566b81fbcbd21eca8ffe808f33b221f9eee2cbc7a1706fc3da9ff18e6cac"}, "makeup_elixir": {:hex, :makeup_elixir, "0.16.2", "627e84b8e8bf22e60a2579dad15067c755531fea049ae26ef1020cad58fe9578", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "41193978704763f6bbe6cc2758b84909e62984c7752b3784bd3c218bb341706b"}, "makeup_erlang": {:hex, :makeup_erlang, "1.0.1", "c7f58c120b2b5aa5fd80d540a89fdf866ed42f1f3994e4fe189abebeab610839", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "8a89a1eeccc2d798d6ea15496a6e4870b75e014d1af514b1b71fa33134f57814"}, + "meck": {:hex, :meck, "0.9.2", "85ccbab053f1db86c7ca240e9fc718170ee5bda03810a6292b5306bf31bae5f5", [:rebar3], [], "hexpm", "81344f561357dc40a8344afa53767c32669153355b626ea9fcbc8da6b3045826"}, "mime": {:hex, :mime, "2.0.6", "8f18486773d9b15f95f4f4f1e39b710045fa1de891fada4516559967276e4dc2", [:mix], [], "hexpm", "c9945363a6b26d747389aac3643f8e0e09d30499a138ad64fe8fd1d13d9b153e"}, "mint": {:hex, :mint, "1.6.2", "af6d97a4051eee4f05b5500671d47c3a67dac7386045d87a904126fd4bbcea2e", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "5ee441dffc1892f1ae59127f74afe8fd82fda6587794278d924e4d90ea3d63f9"}, + "mock": {:hex, :mock, "0.3.9", "10e44ad1f5962480c5c9b9fa779c6c63de9bd31997c8e04a853ec990a9d841af", [:mix], [{:meck, "~> 0.9.2", [hex: :meck, repo: "hexpm", optional: false]}], "hexpm", "9e1b244c4ca2551bb17bb8415eed89e40ee1308e0fbaed0a4fdfe3ec8a4adbd3"}, "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, "nimble_parsec": {:hex, :nimble_parsec, "1.4.0", "51f9b613ea62cfa97b25ccc2c1b4216e81df970acd8e16e8d1bdc58fef21370d", [:mix], [], "hexpm", "9c565862810fb383e9838c1dd2d7d2c437b3d13b267414ba6af33e50d2d1cf28"}, "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, diff --git a/native/yex/src/atoms.rs b/native/yex/src/atoms.rs index bac8360..01d9ff2 100644 --- a/native/yex/src/atoms.rs +++ b/native/yex/src/atoms.rs @@ -34,4 +34,5 @@ rustler::atoms! { delete, retain, attributes, + } diff --git a/native/yex/src/lib.rs b/native/yex/src/lib.rs index cf3214f..6d19209 100644 --- a/native/yex/src/lib.rs +++ b/native/yex/src/lib.rs @@ -12,6 +12,7 @@ mod subscription; mod sync; mod term_box; mod text; +mod undo; mod utils; mod wrap; mod xml; diff --git a/native/yex/src/shared_type.rs b/native/yex/src/shared_type.rs index 72afb24..f4ab07f 100644 --- a/native/yex/src/shared_type.rs +++ b/native/yex/src/shared_type.rs @@ -4,7 +4,6 @@ use std::ops::Deref; use yrs::{Hook, ReadTxn, SharedRef, TransactionMut}; use crate::{ - atoms, doc::{DocResource, ReadTransaction, TransactionResource}, wrap::SliceIntoBinary, }; diff --git a/native/yex/src/undo.rs b/native/yex/src/undo.rs new file mode 100644 index 0000000..9508bc5 --- /dev/null +++ b/native/yex/src/undo.rs @@ -0,0 +1,289 @@ +use crate::{ + shared_type::NifSharedType, utils::term_to_origin_binary, wrap::NifWrap, + yinput::NifSharedTypeInput, NifDoc, NifError, ENV, +}; + +use rustler::{Env, NifStruct, ResourceArc, Term}; +use std::ops::Deref; +use std::sync::RwLock; +use yrs::{undo::Options as UndoOptions, UndoManager}; + +#[derive(NifStruct)] +#[module = "Yex.UndoManager"] +pub struct NifUndoManager { + reference: ResourceArc, +} + +pub struct UndoManagerWrapper { + manager: UndoManager, +} + +impl UndoManagerWrapper { + pub fn new(manager: UndoManager) -> Self { + Self { manager } + } +} + +pub type UndoManagerResource = NifWrap>; + +#[rustler::resource_impl] +impl rustler::Resource for UndoManagerResource {} + +#[derive(NifStruct)] +#[module = "Yex.UndoManager.Options"] +pub struct NifUndoOptions { + pub capture_timeout: u64, +} + +#[rustler::nif] +pub fn undo_manager_new( + env: Env<'_>, + doc: NifDoc, + scope: NifSharedTypeInput, +) -> Result { + ENV.set(&mut env.clone(), || match scope { + NifSharedTypeInput::Text(text) => create_undo_manager(env, doc, text), + NifSharedTypeInput::Array(array) => create_undo_manager(env, doc, array), + NifSharedTypeInput::Map(map) => create_undo_manager(env, doc, map), + NifSharedTypeInput::XmlText(text) => create_undo_manager(env, doc, text), + NifSharedTypeInput::XmlElement(element) => create_undo_manager(env, doc, element), + NifSharedTypeInput::XmlFragment(fragment) => create_undo_manager(env, doc, fragment), + }) +} + +fn create_undo_manager( + env: Env<'_>, + doc: NifDoc, + scope: T, +) -> Result { + create_undo_manager_with_options( + env, + doc, + scope, + NifUndoOptions { + capture_timeout: 500, + }, + ) +} + +fn create_undo_manager_with_options( + _env: Env<'_>, + doc: NifDoc, + scope: T, + options: NifUndoOptions, +) -> Result { + let branch = scope + .readonly(None, |txn| scope.get_ref(txn)) + .map_err(|_| NifError::Message("Failed to get branch reference".to_string()))?; + + let undo_options = UndoOptions { + capture_timeout_millis: options.capture_timeout, + ..Default::default() + }; + + let undo_manager = UndoManager::with_scope_and_options(&doc, &branch, undo_options); + let wrapper = UndoManagerWrapper::new(undo_manager); + + Ok(NifUndoManager { + reference: ResourceArc::new(NifWrap(RwLock::new(wrapper))), + }) +} + +#[rustler::nif] +pub fn undo_manager_new_with_options( + env: Env<'_>, + doc: NifDoc, + scope: NifSharedTypeInput, + options: NifUndoOptions, +) -> Result { + // Check if the document reference is valid by attempting to access its inner doc + // will return an error tuple if it is not + let _doc_ref = doc.reference.deref(); + + match scope { + NifSharedTypeInput::Text(text) => create_undo_manager_with_options(env, doc, text, options), + NifSharedTypeInput::Array(array) => { + create_undo_manager_with_options(env, doc, array, options) + } + NifSharedTypeInput::Map(map) => create_undo_manager_with_options(env, doc, map, options), + NifSharedTypeInput::XmlText(text) => { + create_undo_manager_with_options(env, doc, text, options) + } + NifSharedTypeInput::XmlElement(element) => { + create_undo_manager_with_options(env, doc, element, options) + } + NifSharedTypeInput::XmlFragment(fragment) => { + create_undo_manager_with_options(env, doc, fragment, options) + } + } +} + +#[rustler::nif] +pub fn undo_manager_include_origin( + env: Env<'_>, + undo_manager: NifUndoManager, + origin_term: Term, +) -> Result<(), NifError> { + ENV.set(&mut env.clone(), || { + let mut wrapper = undo_manager + .reference + .0 + .write() + .map_err(|_| NifError::Message("Failed to acquire write lock".to_string()))?; + + let origin = term_to_origin_binary(origin_term) + .ok_or_else(|| NifError::Message("Invalid origin term".to_string()))?; + wrapper.manager.include_origin(origin.as_slice()); + + Ok(()) + }) +} + +#[rustler::nif] +pub fn undo_manager_exclude_origin( + env: Env<'_>, + undo_manager: NifUndoManager, + origin_term: Term, +) -> Result<(), NifError> { + ENV.set(&mut env.clone(), || { + let mut wrapper = undo_manager + .reference + .0 + .write() + .map_err(|_| NifError::Message("Failed to acquire write lock".to_string()))?; + + let origin = term_to_origin_binary(origin_term) + .ok_or_else(|| NifError::Message("Invalid origin term".to_string()))?; + wrapper.manager.exclude_origin(origin.as_slice()); + + Ok(()) + }) +} + +#[rustler::nif] +pub fn undo_manager_undo(env: Env, undo_manager: NifUndoManager) -> Result<(), NifError> { + ENV.set(&mut env.clone(), || { + let mut wrapper = undo_manager + .reference + .0 + .write() + .map_err(|_| NifError::Message("Failed to acquire write lock".to_string()))?; + + if wrapper.manager.can_undo() { + wrapper.manager.undo_blocking(); + } + + Ok(()) + }) +} + +#[rustler::nif] +pub fn undo_manager_redo(env: Env, undo_manager: NifUndoManager) -> Result<(), NifError> { + ENV.set(&mut env.clone(), || { + let mut wrapper = undo_manager + .reference + .0 + .write() + .map_err(|_| NifError::Message("Failed to acquire write lock".to_string()))?; + + if wrapper.manager.can_redo() { + wrapper.manager.redo_blocking(); + } + + Ok(()) + }) +} + +#[rustler::nif] +pub fn undo_manager_expand_scope( + env: Env<'_>, + undo_manager: NifUndoManager, + scope: NifSharedTypeInput, +) -> Result<(), NifError> { + ENV.set(&mut env.clone(), || { + let mut wrapper = undo_manager + .reference + .0 + .write() + .map_err(|_| NifError::Message("Failed to acquire write lock".to_string()))?; + + match scope { + NifSharedTypeInput::Text(text) => { + let branch = text.readonly(None, |txn| text.get_ref(txn)).map_err(|_| { + NifError::Message("Failed to get text branch reference".to_string()) + })?; + wrapper.manager.expand_scope(&branch); + } + NifSharedTypeInput::Array(array) => { + let branch = array + .readonly(None, |txn| array.get_ref(txn)) + .map_err(|_| { + NifError::Message("Failed to get array branch reference".to_string()) + })?; + wrapper.manager.expand_scope(&branch); + } + NifSharedTypeInput::Map(map) => { + let branch = map.readonly(None, |txn| map.get_ref(txn)).map_err(|_| { + NifError::Message("Failed to get map branch reference".to_string()) + })?; + wrapper.manager.expand_scope(&branch); + } + NifSharedTypeInput::XmlText(text) => { + let branch = text.readonly(None, |txn| text.get_ref(txn)).map_err(|_| { + NifError::Message("Failed to get xml text branch reference".to_string()) + })?; + wrapper.manager.expand_scope(&branch); + } + NifSharedTypeInput::XmlElement(element) => { + let branch = element + .readonly(None, |txn| element.get_ref(txn)) + .map_err(|_| { + NifError::Message("Failed to get xml element branch reference".to_string()) + })?; + wrapper.manager.expand_scope(&branch); + } + NifSharedTypeInput::XmlFragment(fragment) => { + let branch = fragment + .readonly(None, |txn| fragment.get_ref(txn)) + .map_err(|_| { + NifError::Message("Failed to get xml fragment branch reference".to_string()) + })?; + wrapper.manager.expand_scope(&branch); + } + } + + Ok(()) + }) +} + +#[rustler::nif] +pub fn undo_manager_stop_capturing( + env: Env<'_>, + undo_manager: NifUndoManager, +) -> Result<(), NifError> { + ENV.set(&mut env.clone(), || { + let mut wrapper = undo_manager + .reference + .0 + .write() + .map_err(|_| NifError::Message("Failed to acquire write lock".to_string()))?; + + wrapper.manager.reset(); + Ok(()) + }) +} + +#[rustler::nif] +pub fn undo_manager_clear(env: Env, undo_manager: NifUndoManager) -> Result<(), NifError> { + ENV.set(&mut env.clone(), || { + let mut wrapper = undo_manager + .reference + .0 + .write() + .map_err(|_| NifError::Message("Failed to acquire write lock".to_string()))?; + + wrapper.manager.clear(); + + Ok(()) + }) +} diff --git a/test/undo_manager_test.exs b/test/undo_manager_test.exs new file mode 100644 index 0000000..2b37746 --- /dev/null +++ b/test/undo_manager_test.exs @@ -0,0 +1,1108 @@ +defmodule Yex.UndoManagerTest do + use ExUnit.Case + import Mock + + alias Yex.{ + Doc, + Text, + TextPrelim, + Array, + UndoManager, + XmlFragment, + XmlElement, + XmlElementPrelim, + XmlText, + XmlTextPrelim + } + + doctest Yex.UndoManager + + setup do + doc = Doc.new() + text = Doc.get_text(doc, "text") + array = Doc.get_array(doc, "array") + map = Doc.get_map(doc, "map") + xml_fragment = Doc.get_xml_fragment(doc, "xml") + # Return these as the test context + {:ok, doc: doc, text: text, array: array, map: map, xml_fragment: xml_fragment} + end + + test "can create an undo manager", %{doc: doc, text: text} do + {:ok, undo_manager} = UndoManager.new(doc, text) + assert %UndoManager{} = undo_manager + assert undo_manager.reference != nil + end + + test "can undo without failure when stack is empty", %{doc: doc, text: text} do + {:ok, undo_manager} = UndoManager.new(doc, text) + UndoManager.undo(undo_manager) + end + + test "can include an origin for tracking", %{doc: doc, text: text} do + {:ok, undo_manager} = UndoManager.new(doc, text) + origin = "test-origin" + UndoManager.include_origin(undo_manager, origin) + + # Make changes with the tracked origin + Doc.transaction(doc, origin, fn -> + Text.insert(text, 0, "tracked") + end) + + # Make changes with an untracked origin + Doc.transaction(doc, "other-origin", fn -> + Text.insert(text, 7, " untracked") + end) + + assert Text.to_string(text) == "tracked untracked" + + # Undo should only remove changes from tracked origin + UndoManager.undo(undo_manager) + assert Text.to_string(text) == " untracked" + end + + test "can undo with no origin with text changes, text removed", %{doc: doc, text: text} do + {:ok, undo_manager} = UndoManager.new(doc, text) + inserted_text = "Hello, world!" + Text.insert(text, 0, inserted_text) + assert Text.to_string(text) == inserted_text + UndoManager.undo(undo_manager) + assert Text.to_string(text) == "" + end + + test "can undo with origin and transaction with text changes, text removed", %{ + doc: doc, + text: text + } do + {:ok, undo_manager} = UndoManager.new(doc, text) + origin = "test-origin" + UndoManager.include_origin(undo_manager, origin) + inserted_text = "Hello, world!" + + Doc.transaction(doc, origin, fn -> + Text.insert(text, 0, inserted_text) + end) + + assert Text.to_string(text) == inserted_text + UndoManager.undo(undo_manager) + assert Text.to_string(text) == "" + end + + test "undo only removes changes from tracked origin for text", %{doc: doc, text: text} do + {:ok, undo_manager} = UndoManager.new(doc, text) + # Set up our tracked origin so that undo manager is only tracking changes from this origin + tracked_origin = "tracked-origin" + UndoManager.include_origin(undo_manager, tracked_origin) + + # Make changes from an untracked origin + untracked_origin = "untracked-origin" + + Doc.transaction(doc, untracked_origin, fn -> + Text.insert(text, 0, "Untracked ") + end) + + # Make changes from our tracked origin + Doc.transaction(doc, tracked_origin, fn -> + Text.insert(text, 10, "changes ") + end) + + # Make more untracked changes + Doc.transaction(doc, untracked_origin, fn -> + Text.insert(text, 18, "remain") + end) + + # Initial state should have all changes + assert Text.to_string(text) == "Untracked changes remain" + + # After undo, only tracked changes should be removed + UndoManager.undo(undo_manager) + assert Text.to_string(text) == "Untracked remain" + end + + test "can undo array changes", %{doc: doc, array: array} do + # Create a new undo manager specifically for the array + {:ok, undo_manager} = UndoManager.new(doc, array) + + # Insert some values + Array.push(array, "first") + Array.push(array, "second") + Array.push(array, "third") + + # Verify initial state + assert Array.to_list(array) == ["first", "second", "third"] + + # Undo the last insertion + UndoManager.undo(undo_manager) + assert Array.to_list(array) == [] + end + + test "can undo map changes", %{doc: doc, map: map} do + {:ok, undo_manager} = UndoManager.new(doc, map) + + # Insert some values + Yex.Map.set(map, "key1", "value1") + Yex.Map.set(map, "key2", "value2") + Yex.Map.set(map, "key3", "value3") + + # Verify initial state + assert Yex.Map.to_map(map) == %{"key1" => "value1", "key2" => "value2", "key3" => "value3"} + + # Undo all changes + UndoManager.undo(undo_manager) + assert Yex.Map.to_map(map) == %{} + end + + test "undo only removes changes from tracked origin for array", %{doc: doc, array: array} do + # Create a new undo manager specifically for the array + {:ok, undo_manager} = UndoManager.new(doc, array) + tracked_origin = "tracked-origin" + UndoManager.include_origin(undo_manager, tracked_origin) + + # Make changes from an untracked origin + untracked_origin = "untracked-origin" + + Doc.transaction(doc, untracked_origin, fn -> + Array.push(array, "untracked1") + Array.push(array, "untracked2") + end) + + # Make changes from tracked origin + Doc.transaction(doc, tracked_origin, fn -> + Array.push(array, "tracked1") + Array.push(array, "tracked2") + end) + + # More untracked changes + Doc.transaction(doc, untracked_origin, fn -> + Array.push(array, "untracked3") + end) + + # Verify initial state + assert Array.to_list(array) == [ + "untracked1", + "untracked2", + "tracked1", + "tracked2", + "untracked3" + ] + + # After undo, only tracked changes should be removed + UndoManager.undo(undo_manager) + assert Array.to_list(array) == ["untracked1", "untracked2", "untracked3"] + end + + test "undo only removes changes from tracked origin for map", %{doc: doc, map: map} do + {:ok, undo_manager} = UndoManager.new(doc, map) + tracked_origin = "tracked-origin" + UndoManager.include_origin(undo_manager, tracked_origin) + + # Make changes from an untracked origin + untracked_origin = "untracked-origin" + + Doc.transaction(doc, untracked_origin, fn -> + Yex.Map.set(map, "untracked1", "value1") + Yex.Map.set(map, "untracked2", "value2") + end) + + # Make changes from tracked origin in a single transaction + Doc.transaction(doc, tracked_origin, fn -> + Yex.Map.set(map, "tracked1", "value3") + Yex.Map.set(map, "tracked2", "value4") + end) + + # More untracked changes in a single transaction + Doc.transaction(doc, untracked_origin, fn -> + Yex.Map.set(map, "untracked3", "value5") + end) + + # Let's ensure all transactions are complete before proceeding + Process.sleep(10) + + # Verify initial state + expected_initial = %{ + "untracked1" => "value1", + "untracked2" => "value2", + "tracked1" => "value3", + "tracked2" => "value4", + "untracked3" => "value5" + } + + assert Yex.Map.to_map(map) == expected_initial + + # After undo, only tracked changes should be removed + UndoManager.undo(undo_manager) + + # Give time for the undo operation to complete + Process.sleep(10) + + expected_after_undo = %{ + "untracked1" => "value1", + "untracked2" => "value2", + "untracked3" => "value5" + } + + assert Yex.Map.to_map(map) == expected_after_undo + end + + test "can redo without failure when stack is empty", %{doc: doc, text: text} do + {:ok, undo_manager} = UndoManager.new(doc, text) + UndoManager.redo(undo_manager) + end + + test "can redo text changes after undo", %{doc: doc, text: text} do + {:ok, undo_manager} = UndoManager.new(doc, text) + inserted_text = "Hello, world!" + Text.insert(text, 0, inserted_text) + + # Verify initial state and undo + assert Text.to_string(text) == inserted_text + UndoManager.undo(undo_manager) + assert Text.to_string(text) == "" + + # Verify redo restores the change + UndoManager.redo(undo_manager) + assert Text.to_string(text) == inserted_text + end + + test "can redo array changes after undo", %{doc: doc, array: array} do + {:ok, undo_manager} = UndoManager.new(doc, array) + + # Make some changes + Array.push(array, "first") + Array.push(array, "second") + + # Verify initial state + assert Array.to_list(array) == ["first", "second"] + + # Undo and verify + UndoManager.undo(undo_manager) + assert Array.to_list(array) == [] + + # Redo and verify restoration + UndoManager.redo(undo_manager) + assert Array.to_list(array) == ["first", "second"] + end + + test "can redo map changes after undo", %{doc: doc, map: map} do + {:ok, undo_manager} = UndoManager.new(doc, map) + + # Make some changes + Yex.Map.set(map, "key1", "value1") + Yex.Map.set(map, "key2", "value2") + + # Verify initial state + assert Yex.Map.to_map(map) == %{"key1" => "value1", "key2" => "value2"} + + # Undo and verify + UndoManager.undo(undo_manager) + assert Yex.Map.to_map(map) == %{} + + # Redo and verify restoration + UndoManager.redo(undo_manager) + assert Yex.Map.to_map(map) == %{"key1" => "value1", "key2" => "value2"} + end + + test "redo only affects tracked origin changes", %{doc: doc, text: text} do + {:ok, undo_manager} = UndoManager.new(doc, text) + tracked_origin = "tracked-origin" + UndoManager.include_origin(undo_manager, tracked_origin) + + # Make untracked changes + Doc.transaction(doc, "untracked-origin", fn -> + Text.insert(text, 0, "Untracked ") + end) + + # Make tracked changes + Doc.transaction(doc, tracked_origin, fn -> + Text.insert(text, 10, "tracked ") + end) + + # Initial state + assert Text.to_string(text) == "Untracked tracked " + + # Undo tracked changes + UndoManager.undo(undo_manager) + assert Text.to_string(text) == "Untracked " + + # Redo tracked changes + UndoManager.redo(undo_manager) + assert Text.to_string(text) == "Untracked tracked " + end + + test "works with all types", %{doc: doc, text: text, array: array, map: map} do + {:ok, text_manager} = UndoManager.new(doc, text) + {:ok, array_manager} = UndoManager.new(doc, array) + {:ok, map_manager} = UndoManager.new(doc, map) + + # Test with text + Text.insert(text, 0, "Hello") + assert Text.to_string(text) == "Hello" + UndoManager.undo(text_manager) + assert Text.to_string(text) == "" + UndoManager.redo(text_manager) + assert Text.to_string(text) == "Hello" + + # Test with array + Array.push(array, "item") + assert Array.to_list(array) == ["item"] + UndoManager.undo(array_manager) + assert Array.to_list(array) == [] + UndoManager.redo(array_manager) + assert Array.to_list(array) == ["item"] + + # Test with map + Yex.Map.set(map, "key", "value") + assert Yex.Map.to_map(map) == %{"key" => "value"} + UndoManager.undo(map_manager) + assert Yex.Map.to_map(map) == %{} + UndoManager.redo(map_manager) + assert Yex.Map.to_map(map) == %{"key" => "value"} + end + + test "can expand scope to include additional text", %{doc: doc, text: text} do + {:ok, undo_manager} = UndoManager.new(doc, text) + additional_text = Doc.get_text(doc, "additional_text") + + # Add text to both shared types + Text.insert(text, 0, "Original") + Text.insert(additional_text, 0, "Additional") + + # Initially, undo only affects original text + UndoManager.undo(undo_manager) + assert Text.to_string(text) == "" + assert Text.to_string(additional_text) == "Additional" + + # Expand scope to include additional text + UndoManager.expand_scope(undo_manager, additional_text) + + # New changes should affect both + Text.insert(text, 0, "New Original") + Text.insert(additional_text, 0, "New Additional") + + UndoManager.undo(undo_manager) + assert Text.to_string(text) == "" + assert Text.to_string(additional_text) == "Additional" + end + + test "can expand scope to include multiple types", %{ + doc: doc, + text: text, + array: array, + map: map + } do + {:ok, undo_manager} = UndoManager.new(doc, text) + + # Expand scope to include array and map + UndoManager.expand_scope(undo_manager, array) + UndoManager.expand_scope(undo_manager, map) + + # Make changes to all types + Text.insert(text, 0, "Text") + Array.push(array, "Array") + Yex.Map.set(map, "key", "Map") + + # Verify initial state + assert Text.to_string(text) == "Text" + assert Array.to_list(array) == ["Array"] + assert Yex.Map.to_map(map) == %{"key" => "Map"} + + # Undo should affect all types + UndoManager.undo(undo_manager) + assert Text.to_string(text) == "" + assert Array.to_list(array) == [] + assert Yex.Map.to_map(map) == %{} + end + + test "can exclude an origin from tracking", %{doc: doc, text: text} do + {:ok, undo_manager} = UndoManager.new(doc, text) + origin = "test-origin" + UndoManager.exclude_origin(undo_manager, origin) + end + + test "excluded origin changes are not tracked", %{doc: doc, text: text} do + {:ok, undo_manager} = UndoManager.new(doc, text) + excluded_origin = "excluded-origin" + UndoManager.exclude_origin(undo_manager, excluded_origin) + + # Make changes with excluded origin + Doc.transaction(doc, excluded_origin, fn -> + Text.insert(text, 0, "Excluded ") + end) + + # Make changes with unspecified origin + Text.insert(text, 9, "tracked ") + + # Make changes with excluded origin + Doc.transaction(doc, excluded_origin, fn -> + Text.insert(text, 17, "Also Excluded") + end) + + # Initial state should have all changes + assert Text.to_string(text) == "Excluded tracked Also Excluded" + + # After undo, only non-excluded changes should be removed + UndoManager.undo(undo_manager) + assert Text.to_string(text) == "Excluded Also Excluded" + end + + test "stop_capturing prevents merging of changes", %{doc: doc, text: text} do + {:ok, undo_manager} = UndoManager.new(doc, text) + + # prove changes are merging + Text.insert(text, 0, "a") + Text.insert(text, 1, "b") + assert Text.to_string(text) == "ab" + UndoManager.undo(undo_manager) + assert Text.to_string(text) == "" + + # do it again with stop capture + Text.insert(text, 0, "a") + # Stop capturing to prevent merging + UndoManager.stop_capturing(undo_manager) + + # Second change + Text.insert(text, 1, "b") + + # Initial state should have both changes + assert Text.to_string(text) == "ab" + + # Undo should only remove the second change + UndoManager.undo(undo_manager) + assert Text.to_string(text) == "a" + end + + test "changes merge without stop_capturing", %{doc: doc, text: text} do + {:ok, undo_manager} = UndoManager.new(doc, text) + + # Make two changes in quick succession + Text.insert(text, 0, "a") + Text.insert(text, 1, "b") + + # Initial state should have both changes + assert Text.to_string(text) == "ab" + + # Undo should remove both changes since they were merged + UndoManager.undo(undo_manager) + assert Text.to_string(text) == "" + end + + test "stop_capturing works with different types", %{doc: doc, array: array} do + {:ok, undo_manager} = UndoManager.new(doc, array) + + # First change + Array.push(array, "first") + + # Stop capturing to prevent merging + UndoManager.stop_capturing(undo_manager) + + # Second change + Array.push(array, "second") + + # Initial state should have both items + assert Array.to_list(array) == ["first", "second"] + + # Undo should only remove the second item + UndoManager.undo(undo_manager) + assert Array.to_list(array) == ["first"] + end + + test "clear removes all stack items", %{doc: doc, text: text} do + {:ok, undo_manager} = UndoManager.new(doc, text) + + # Make some changes that will create undo stack items + Text.insert(text, 0, "Hello") + Text.insert(text, 5, " World") + assert Text.to_string(text) == "Hello World" + + # Clear the undo manager + UndoManager.clear(undo_manager) + + # Try to undo - should have no effect since stack was cleared + UndoManager.undo(undo_manager) + assert Text.to_string(text) == "Hello World" + + # Try to redo - should have no effect since stack was cleared + UndoManager.redo(undo_manager) + assert Text.to_string(text) == "Hello World" + end + + test "can create an undo manager with options", %{doc: doc, text: text} do + options = %UndoManager.Options{capture_timeout: 1000} + {:ok, undo_manager} = UndoManager.new_with_options(doc, text, options) + assert %UndoManager{} = undo_manager + assert undo_manager.reference != nil + end + + test "capture timeout works as expected", %{doc: doc, text: text} do + options = %UndoManager.Options{capture_timeout: 100} + {:ok, undo_manager} = UndoManager.new_with_options(doc, text, options) + + Text.insert(text, 0, "a") + + # se are testing Undo manager's ability to batch after timeout, 150ms should create two batches + Process.sleep(150) + Text.insert(text, 1, "b") + + UndoManager.undo(undo_manager) + # 'a' still remains due to timeout + assert Text.to_string(text) == "a" + end + + test "demonstrates constructor with options", %{doc: doc, text: text} do + options = %UndoManager.Options{capture_timeout: 100} + {:ok, undo_manager} = UndoManager.new_with_options(doc, text, options) + # prove tests are batched + Text.insert(text, 0, "a") + Text.insert(text, 1, "b") + assert Text.to_string(text) == "ab" + + UndoManager.undo(undo_manager) + assert Text.to_string(text) == "" + + # Prove options are respected + Text.insert(text, 0, "c") + + # sleep longer than capture_timeout to ensure two batches are created + Process.sleep(150) + Text.insert(text, 1, "d") + assert Text.to_string(text) == "cd" + + UndoManager.undo(undo_manager) + + # 'c' still remains due to timeout + assert Text.to_string(text) == "c" + UndoManager.undo(undo_manager) + # get back to empty + assert Text.to_string(text) == "" + + # Prove option means insufficient timeout will still batch + Text.insert(text, 0, "e") + + # undo manager has a timeout of 100ms, so this sleep of 50ms should ... + # ... be insufficient and will allow the changes to be in one batch + Process.sleep(50) + Text.insert(text, 1, "f") + assert Text.to_string(text) == "ef" + + UndoManager.undo(undo_manager) + assert Text.to_string(text) == "" + end + + test "basic constructor example", %{doc: doc, text: text} do + # From docs: const undoManager = new Y.UndoManager(ytext) + {:ok, undo_manager} = UndoManager.new(doc, text) + assert %UndoManager{} = undo_manager + end + + test "demonstrates exact stopCapturing behavior from docs", %{doc: doc, text: text} do + {:ok, undo_manager} = UndoManager.new(doc, text) + + # Example from docs: + # // without stopCapturing + Text.insert(text, 0, "a") + Text.insert(text, 1, "b") + UndoManager.undo(undo_manager) + # note that 'ab' was removed + assert Text.to_string(text) == "" + + # Reset state + Text.delete(text, 0, Text.length(text)) + + # Example from docs: + # // with stopCapturing + Text.insert(text, 0, "a") + # Ensure subsequent changes are captured separately + UndoManager.stop_capturing(undo_manager) + Text.insert(text, 1, "b") + UndoManager.undo(undo_manager) + # note that only 'b' was removed + assert Text.to_string(text) == "a" + end + + test "demonstrates tracking specific origins from docs", %{doc: doc, text: text} do + {:ok, undo_manager} = UndoManager.new(doc, text) + + # From docs: undoManager.addToScope(ytext) + UndoManager.include_origin(undo_manager, "my-origin") + + # Make changes with tracked origin + Doc.transaction(doc, "my-origin", fn -> + Text.insert(text, 0, "tracked changes") + end) + + # Make changes with untracked origin + Doc.transaction(doc, "other-origin", fn -> + Text.insert(text, 0, "untracked ") + end) + + assert Text.to_string(text) == "untracked tracked changes" + + # Undo should only affect tracked changes + UndoManager.undo(undo_manager) + assert Text.to_string(text) == "untracked " + end + + test "demonstrates clear functionality from docs", %{doc: doc, text: text} do + {:ok, undo_manager} = UndoManager.new(doc, text) + + # Make some changes + Text.insert(text, 0, "hello") + Text.insert(text, 5, " world") + assert Text.to_string(text) == "hello world" + + # From docs: undoManager.clear() + UndoManager.clear(undo_manager) + + # Verify undo/redo have no effect after clear + UndoManager.undo(undo_manager) + assert Text.to_string(text) == "hello world" + UndoManager.redo(undo_manager) + assert Text.to_string(text) == "hello world" + end + + test "demonstrates scope expansion from docs", %{doc: doc, text: text} do + {:ok, undo_manager} = UndoManager.new(doc, text) + additional_text = Doc.get_text(doc, "additional_text") + + # From docs: undoManager.addToScope(additionalYText) + UndoManager.expand_scope(undo_manager, additional_text) + + # Make changes to both texts + Text.insert(text, 0, "first text") + Text.insert(additional_text, 0, "second text") + + # Undo should affect both texts + UndoManager.undo(undo_manager) + assert Text.to_string(text) == "" + assert Text.to_string(additional_text) == "" + end + + defmodule CustomBinding do + # Just a marker module to match the JavaScript example + end + + test "demonstrates tracked origins specification from docs", %{doc: doc, text: text} do + # Mirror the docs setup: + # const undoManager = new Y.UndoManager(ytext, { + # trackedOrigins: new Set([42, CustomBinding]) + # }) + {:ok, undo_manager} = UndoManager.new(doc, text) + UndoManager.include_origin(undo_manager, 42) + UndoManager.include_origin(undo_manager, CustomBinding) + + # First example: untracked origin (null) + Text.insert(text, 0, "abc") + UndoManager.undo(undo_manager) + # not tracked because origin is null + assert Text.to_string(text) == "abc" + # revert change + Text.delete(text, 0, 3) + + # Second example: tracked origin (42) + Doc.transaction(doc, 42, fn -> + Text.insert(text, 0, "abc") + end) + + UndoManager.undo(undo_manager) + # tracked because origin is 42 + assert Text.to_string(text) == "" + + # Third example: untracked origin (41) + Doc.transaction(doc, 41, fn -> + Text.insert(text, 0, "abc") + end) + + UndoManager.undo(undo_manager) + # not tracked because 41 isn't in tracked origins + assert Text.to_string(text) == "abc" + # revert change + Text.delete(text, 0, 3) + + # Fourth example: tracked origin (CustomBinding) + Doc.transaction(doc, CustomBinding, fn -> + Text.insert(text, 0, "abc") + end) + + UndoManager.undo(undo_manager) + # tracked because CustomBinding is in tracked origins + assert Text.to_string(text) == "" + end + + test "multiple undo/redo sequences work correctly", %{doc: doc, text: text} do + {:ok, undo_manager} = UndoManager.new(doc, text) + + # First change + Text.insert(text, 0, "Hello") + + # stop tracking to ensure changes are not batched + UndoManager.stop_capturing(undo_manager) + + # Second change + Text.insert(text, 5, " World") + assert Text.to_string(text) == "Hello World" + + # First undo + UndoManager.undo(undo_manager) + assert Text.to_string(text) == "Hello" + + # First redo + UndoManager.redo(undo_manager) + assert Text.to_string(text) == "Hello World" + + # Undo both changes + UndoManager.undo(undo_manager) + UndoManager.undo(undo_manager) + assert Text.to_string(text) == "" + + # Redo both changes + UndoManager.redo(undo_manager) + UndoManager.redo(undo_manager) + assert Text.to_string(text) == "Hello World" + end + + test "redo stack is cleared when new changes are made", %{doc: doc, text: text} do + {:ok, undo_manager} = UndoManager.new(doc, text) + + # Initial change + Text.insert(text, 0, "Hello") + + # Undo the change + UndoManager.undo(undo_manager) + assert Text.to_string(text) == "" + + # Make a new change instead of redo + Text.insert(text, 0, "Different") + + # Try to redo - should have no effect since we made a new change + UndoManager.redo(undo_manager) + assert Text.to_string(text) == "Different" + end + + test "redo with multiple types in scope", %{doc: doc, text: text, array: array} do + {:ok, undo_manager} = UndoManager.new(doc, text) + UndoManager.expand_scope(undo_manager, array) + + # Make changes to both types + Text.insert(text, 0, "Hello") + Array.push(array, "World") + + # Verify initial state + assert Text.to_string(text) == "Hello" + assert Array.to_list(array) == ["World"] + + # Undo changes to both types + UndoManager.undo(undo_manager) + assert Text.to_string(text) == "" + assert Array.to_list(array) == [] + + # Redo should restore both changes + UndoManager.redo(undo_manager) + assert Text.to_string(text) == "Hello" + assert Array.to_list(array) == ["World"] + end + + test "new_with_options unwraps successful results", %{ + doc: doc, + text: text, + array: array, + map: map + } do + options = %UndoManager.Options{capture_timeout: 1000} + + # Test Text type + {:ok, text_manager} = UndoManager.new_with_options(doc, text, options) + assert match?(%UndoManager{}, text_manager) + assert text_manager.reference != nil + + # Test Array type + {:ok, array_manager} = UndoManager.new_with_options(doc, array, options) + assert match?(%UndoManager{}, array_manager) + assert array_manager.reference != nil + + # Test Map type + {:ok, map_manager} = UndoManager.new_with_options(doc, map, options) + assert match?(%UndoManager{}, map_manager) + assert map_manager.reference != nil + end + + test "undo works with embedded Yex objects", %{doc: doc} do + # Create an array to hold our embedded text + array = Doc.get_array(doc, "array") + {:ok, undo_manager} = UndoManager.new(doc, array) + + # Create a text object with initial content + text_prelim = TextPrelim.from("Initial") + + # Push the text into the array + Array.push(array, text_prelim) + + # Fetch the text from the array and verify initial content + {:ok, embedded_text} = Array.fetch(array, 0) + assert Text.to_string(embedded_text) == "Initial" + + # Stop capturing to prevent merging the push and insert operations + UndoManager.stop_capturing(undo_manager) + + # Insert additional text + Text.insert(embedded_text, 7, " Content") + assert Text.to_string(embedded_text) == "Initial Content" + + # Undo should revert the inserted text but keep the preliminary text + UndoManager.undo(undo_manager) + assert Text.to_string(embedded_text) == "Initial" + end + + test "can undo xml fragment changes", %{doc: doc, xml_fragment: xml_fragment} do + {:ok, undo_manager} = UndoManager.new(doc, xml_fragment) + + # Add some XML content + XmlFragment.push(xml_fragment, XmlTextPrelim.from("Hello")) + XmlFragment.push(xml_fragment, XmlElementPrelim.empty("div")) + + # Verify initial state + assert XmlFragment.to_string(xml_fragment) == "Hello
" + + # Undo changes + UndoManager.undo(undo_manager) + assert XmlFragment.to_string(xml_fragment) == "" + end + + test "can undo xml element changes", %{doc: doc, xml_fragment: xml_fragment} do + # First create an element in the fragment + XmlFragment.push(xml_fragment, XmlElementPrelim.empty("div")) + {:ok, element} = XmlFragment.fetch(xml_fragment, 0) + + {:ok, undo_manager} = UndoManager.new(doc, element) + + # Add attributes and content + XmlElement.insert_attribute(element, "class", "test") + XmlElement.push(element, XmlTextPrelim.from("content")) + + # Verify initial state + assert XmlElement.to_string(element) == "
content
" + + # Undo changes + UndoManager.undo(undo_manager) + assert XmlElement.to_string(element) == "
" + end + + test "can undo xml text changes", %{doc: doc, xml_fragment: xml_fragment} do + # First create a text node in the fragment + XmlFragment.push(xml_fragment, XmlTextPrelim.from("")) + {:ok, text_node} = XmlFragment.fetch(xml_fragment, 0) + + {:ok, undo_manager} = UndoManager.new(doc, text_node) + + # Add content and formatting + XmlText.insert(text_node, 0, "Hello World") + XmlText.format(text_node, 0, 5, %{"bold" => true}) + + # Verify initial state + assert XmlText.to_string(text_node) == "Hello World" + + # Undo changes + UndoManager.undo(undo_manager) + assert XmlText.to_string(text_node) == "" + end + + test "undo only removes changes from tracked origin for xml", %{ + doc: doc, + xml_fragment: xml_fragment + } do + {:ok, undo_manager} = UndoManager.new(doc, xml_fragment) + tracked_origin = "tracked-origin" + UndoManager.include_origin(undo_manager, tracked_origin) + + # Make untracked changes + Doc.transaction(doc, "untracked-origin", fn -> + XmlFragment.push(xml_fragment, XmlTextPrelim.from("untracked")) + end) + + # Make tracked changes + Doc.transaction(doc, tracked_origin, fn -> + XmlFragment.push(xml_fragment, XmlElementPrelim.empty("div")) + end) + + # Make more untracked changes + Doc.transaction(doc, "untracked-origin", fn -> + XmlFragment.push(xml_fragment, XmlTextPrelim.from("more-untracked")) + end) + + # Verify initial state + assert XmlFragment.to_string(xml_fragment) == "untracked
more-untracked" + + # Undo should only remove tracked changes + UndoManager.undo(undo_manager) + assert XmlFragment.to_string(xml_fragment) == "untrackedmore-untracked" + end + + test "can redo xml changes", %{doc: doc, xml_fragment: xml_fragment} do + {:ok, undo_manager} = UndoManager.new(doc, xml_fragment) + + # Make some changes + XmlFragment.push(xml_fragment, XmlTextPrelim.from("Hello")) + XmlFragment.push(xml_fragment, XmlElementPrelim.empty("div")) + + # Verify initial state + assert XmlFragment.to_string(xml_fragment) == "Hello
" + + # Undo changes + UndoManager.undo(undo_manager) + assert XmlFragment.to_string(xml_fragment) == "" + + # Redo changes + UndoManager.redo(undo_manager) + assert XmlFragment.to_string(xml_fragment) == "Hello
" + end + + test "works with nested xml structure", %{doc: doc, xml_fragment: xml_fragment} do + {:ok, undo_manager} = UndoManager.new(doc, xml_fragment) + + # Create a nested structure + XmlFragment.push( + xml_fragment, + XmlElementPrelim.new("div", [ + XmlElementPrelim.new("span", [ + XmlTextPrelim.from("nested content") + ]) + ]) + ) + + # Verify initial state + assert XmlFragment.to_string(xml_fragment) == "
nested content
" + + # Undo should remove entire structure + UndoManager.undo(undo_manager) + assert XmlFragment.to_string(xml_fragment) == "" + + # Redo should restore entire structure + UndoManager.redo(undo_manager) + assert XmlFragment.to_string(xml_fragment) == "
nested content
" + end + + test "returns error when trying to create undo manager with invalid document", %{text: text} do + invalid_doc = %{not: "a valid doc"} + + assert_raise ArgumentError, fn -> + UndoManager.new(invalid_doc, text) + end + end + + test "guards prevent invalid scope in new/2" do + doc = Doc.new() + invalid_scope = %{not: "a valid scope"} + + assert_raise FunctionClauseError, fn -> + UndoManager.new(doc, invalid_scope) + end + end + + test "guards prevent invalid scope in new_with_options/3" do + doc = Doc.new() + invalid_scope = %{not: "a valid scope"} + options = %UndoManager.Options{capture_timeout: 1000} + + assert_raise FunctionClauseError, fn -> + UndoManager.new_with_options(doc, invalid_scope, options) + end + end + + test "guards prevent invalid options in new_with_options/3", %{doc: doc, text: text} do + invalid_options = %{not: "valid options"} + + assert_raise FunctionClauseError, fn -> + UndoManager.new_with_options(doc, text, invalid_options) + end + end + + test "guards allow valid scope types", %{doc: doc} do + # Test each valid scope type + text = Doc.get_text(doc, "text") + array = Doc.get_array(doc, "array") + map = Doc.get_map(doc, "map") + xml_fragment = Doc.get_xml_fragment(doc, "xml_fragment") + + # All of these should work without raising + {:ok, _} = UndoManager.new(doc, text) + {:ok, _} = UndoManager.new(doc, array) + {:ok, _} = UndoManager.new(doc, map) + {:ok, _} = UndoManager.new(doc, xml_fragment) + end + + test "guards allow valid scope types with options", %{doc: doc} do + # Test each valid scope type + text = Doc.get_text(doc, "text") + array = Doc.get_array(doc, "array") + map = Doc.get_map(doc, "map") + xml_fragment = Doc.get_xml_fragment(doc, "xml_fragment") + options = %UndoManager.Options{capture_timeout: 1000} + + # All of these should work without raising + {:ok, _} = UndoManager.new_with_options(doc, text, options) + {:ok, _} = UndoManager.new_with_options(doc, array, options) + {:ok, _} = UndoManager.new_with_options(doc, map, options) + {:ok, _} = UndoManager.new_with_options(doc, xml_fragment, options) + end + + test "scope validation works correctly", %{doc: doc} do + # Test valid scopes + text = Doc.get_text(doc, "text") + array = Doc.get_array(doc, "array") + map = Doc.get_map(doc, "map") + xml_fragment = Doc.get_xml_fragment(doc, "xml_fragment") + + # These should all return {:ok, _} results + assert {:ok, _} = UndoManager.new(doc, text) + assert {:ok, _} = UndoManager.new(doc, array) + assert {:ok, _} = UndoManager.new(doc, map) + assert {:ok, _} = UndoManager.new(doc, xml_fragment) + + # Test invalid scopes + invalid_scope = %{not: "a valid scope"} + + assert_raise FunctionClauseError, fn -> + UndoManager.new(doc, invalid_scope) + end + + assert_raise FunctionClauseError, fn -> + UndoManager.new(doc, nil) + end + + assert_raise FunctionClauseError, fn -> + UndoManager.new(doc, "string") + end + + assert_raise FunctionClauseError, fn -> + UndoManager.new(doc, 123) + end + end + + test "defguard is_valid_scope can be imported and used" do + import Yex.UndoManager, only: [is_valid_scope: 1] + doc = Doc.new() + text = Doc.get_text(doc, "text") + invalid_scope = %{not: "a valid scope"} + + # Test valid scope + assert is_valid_scope(text) + + # Test invalid scope + refute is_valid_scope(invalid_scope) + refute is_valid_scope(nil) + refute is_valid_scope("string") + refute is_valid_scope(123) + end + + test "new_with_options handles NIF errors", %{doc: doc, text: text} do + # Invalid timeout to trigger error + options = %UndoManager.Options{capture_timeout: -1} + + # Mock the NIF call to return an error + with_mock Yex.Nif, + undo_manager_new_with_options: fn _doc, _scope, _options -> + {:error, "test error message"} + end do + assert {:error, "NIF error: test error message"} = + UndoManager.new_with_options(doc, text, options) + end + end +end