@@ -33,7 +33,7 @@ defmodule LiveDebugger.App.Debugger.Web.Components.ElixirDisplay do
<.collapsible
:if={@has_children?}
id={@id <> "collapsible"}
- open={@expanded?}
+ open={@auto_open?}
icon="icon-chevron-right"
label_class="max-w-max"
chevron_class="text-code-2 m-auto w-[2ch] h-[2ch]"
@@ -66,7 +66,7 @@ defmodule LiveDebugger.App.Debugger.Web.Components.ElixirDisplay do
attr(:items, :list, required: true)
- defp text_items(assigns) do
+ def text_items(assigns) do
~H"""
<%= for item <- @items do %>
@@ -82,15 +82,9 @@ defmodule LiveDebugger.App.Debugger.Web.Components.ElixirDisplay do
if color, do: "#{color}", else: ""
end
- defp auto_expand?(%TermNode{}, 1), do: true
+ defp auto_open?(%TermNode{}, 1), do: true
- defp auto_expand?(%TermNode{} = node, _level) do
- node.kind == "tuple" and children_number(node) <= @max_auto_expand_size
+ defp auto_open?(%TermNode{} = node, _level) do
+ node.kind == "tuple" and TermNode.children_number(node) <= @max_auto_expand_size
end
-
- defp has_children?(%TermNode{children: []}), do: false
- defp has_children?(%TermNode{}), do: true
-
- defp children_number(%TermNode{children: nil}), do: 0
- defp children_number(%TermNode{children: children}), do: length(children)
end
diff --git a/lib/live_debugger/app/debugger/web/live_components/optimized_elixir_display.ex b/lib/live_debugger/app/debugger/web/live_components/optimized_elixir_display.ex
new file mode 100644
index 000000000..4529ff00e
--- /dev/null
+++ b/lib/live_debugger/app/debugger/web/live_components/optimized_elixir_display.ex
@@ -0,0 +1,112 @@
+defmodule LiveDebugger.App.Debugger.Web.LiveComponents.OptimizedElixirDisplay do
+ @moduledoc """
+ Optimized ElixirDisplay LiveComponent that can be used to display a tree of terms.
+ It removes children of collapsed nodes from HTML, and adds them when the node is opened.
+ """
+
+ use LiveDebugger.App.Web, :live_component
+
+ alias LiveDebugger.App.Debugger.Web.Components.ElixirDisplay
+ alias LiveDebugger.App.Utils.TermParser.TermNode
+
+ @impl true
+ def update(assigns, socket) do
+ socket
+ |> assign(:id, assigns.id)
+ |> assign(:node, assigns.node)
+ |> ok()
+ end
+
+ attr(:id, :string, required: true)
+ attr(:node, TermNode, required: true)
+
+ @impl true
+ def render(assigns) do
+ ~H"""
+
+ <.term id={@id} node={@node} myself={@myself} />
+
+ """
+ end
+
+ @impl true
+ def handle_event("toggle_node", %{"id" => id}, socket) do
+ node = update_node_children(socket.assigns.node, id)
+
+ socket
+ |> assign(:node, node)
+ |> noreply()
+ end
+
+ attr(:id, :string, required: true)
+ attr(:node, TermNode, required: true)
+ attr(:myself, :any, required: true)
+
+ defp term(assigns) do
+ assigns =
+ assigns
+ |> assign(:has_children?, TermNode.has_children?(assigns.node))
+
+ ~H"""
+
+ <%= if @has_children? do %>
+ <.static_collapsible
+ open={@node.open?}
+ label_class="max-w-max"
+ chevron_class="text-code-2 m-auto w-[2ch] h-[2ch]"
+ phx-click="toggle_node"
+ phx-value-id={@node.id}
+ phx-target={@myself}
+ >
+ <:label :let={open}>
+ <%= if open do %>
+
+ <% else %>
+
+ <% end %>
+
+
+ <%= for {child, index} <- Enum.with_index(@node.children) do %>
+ -
+ <.term id={@id <> "-#{index}"} node={child} myself={@myself} />
+
+ <% end %>
+
+
+
+
+
+ <% else %>
+
+
+
+ <% end %>
+
+ """
+ end
+
+ defp update_node_children(node, "root"), do: %TermNode{node | open?: !node.open?}
+
+ defp update_node_children(node, id) do
+ ["root" | id_path] = id |> String.split(".")
+
+ id_path = Enum.map(id_path, &String.to_integer(&1))
+
+ recursively_update_node_children(node, id_path)
+ end
+
+ defp recursively_update_node_children(node, []) when is_struct(node, TermNode) do
+ %TermNode{node | open?: !node.open?}
+ end
+
+ defp recursively_update_node_children(node, [id | rest]) when is_struct(node, TermNode) do
+ child_node =
+ node.children
+ |> Enum.at(id)
+ |> recursively_update_node_children(rest)
+
+ updated_children = List.replace_at(node.children, id, child_node)
+
+ %TermNode{node | children: updated_children}
+ end
+end
diff --git a/lib/live_debugger/app/utils/term_differ.ex b/lib/live_debugger/app/utils/term_differ.ex
new file mode 100644
index 000000000..4466ae824
--- /dev/null
+++ b/lib/live_debugger/app/utils/term_differ.ex
@@ -0,0 +1,113 @@
+defmodule LiveDebugger.App.Utils.TermDiffer do
+ @moduledoc """
+ Module for getting diffs between two terms.
+ """
+
+ defmodule Diff do
+ defstruct [:type, ins: [], del: [], diff: nil]
+
+ @type type() :: :map | :list | :tuple | :struct | :primitive
+ @type t() ::
+ %__MODULE__{
+ type: type(),
+ ins: [atom() | non_neg_integer()],
+ del: [atom() | non_neg_integer()],
+ diff: %{atom() => t()} | nil
+ }
+ end
+
+ @spec diff(term(), term()) :: Diff.t() | nil
+ def diff(term1, term2) when term1 === term2, do: nil
+
+ def diff(list1, list2) when is_list(list1) and is_list(list2) do
+ {ins, del} = do_list_index_diff(list1, list2)
+
+ %Diff{type: :list, ins: ins, del: del}
+ end
+
+ def diff(%struct{} = struct1, %struct{} = struct2) do
+ map1 = Map.from_struct(struct1)
+ map2 = Map.from_struct(struct2)
+ {_, _, diff} = do_map_index_diff(map1, map2)
+
+ %Diff{type: :struct, diff: diff}
+ end
+
+ def diff(struct1, struct2) when is_struct(struct1) and is_struct(struct2) do
+ %Diff{type: :primitive}
+ end
+
+ def diff(map1, map2) when is_map(map1) and is_map(map2) do
+ {ins, del, diff} = do_map_index_diff(map1, map2)
+
+ %Diff{type: :map, ins: ins, del: del, diff: diff}
+ end
+
+ def diff(tuple1, tuple2) when is_tuple(tuple1) and is_tuple(tuple2) do
+ list1 = Tuple.to_list(tuple1)
+ list2 = Tuple.to_list(tuple2)
+
+ {ins, del} = do_list_index_diff(list1, list2)
+
+ %Diff{type: :tuple, ins: ins, del: del}
+ end
+
+ def diff(_, _), do: %Diff{type: :primitive}
+
+ defp do_list_index_diff(list1, list2) do
+ diffs =
+ list1
+ |> List.myers_difference(list2)
+ |> Enum.group_by(fn {key, _} -> key end, fn {_, values} -> values end)
+
+ ins_values = Map.get(diffs, :ins, []) |> List.flatten()
+ del_values = Map.get(diffs, :del, []) |> List.flatten()
+
+ ins_indexes = indexes_from_values(ins_values, list2)
+ del_indexes = indexes_from_values(del_values, list1)
+
+ {ins_indexes, del_indexes}
+ end
+
+ defp indexes_from_values(values, list) do
+ {indexes, _} =
+ Enum.reduce(values, {[], list}, fn value, {indexes, list} ->
+ index = Enum.find_index(list, &(&1 === value))
+
+ values = List.replace_at(list, index, :live_debugger_value_used)
+
+ {[index | indexes], values}
+ end)
+
+ indexes
+ end
+
+ defp do_map_index_diff(map1, map2) do
+ map1_keys = Map.keys(map1)
+ map2_keys = Map.keys(map2)
+
+ key_del = map1_keys -- map2_keys
+ key_ins = map2_keys -- map1_keys
+
+ key_diff =
+ MapSet.intersection(MapSet.new(map1_keys), MapSet.new(map2_keys))
+
+ key_diff_map =
+ key_diff
+ |> Enum.map(fn key ->
+ value1 = Map.fetch!(map1, key)
+ value2 = Map.fetch!(map2, key)
+
+ diff = diff(value1, value2)
+
+ {key, diff}
+ end)
+ |> Enum.filter(fn {_, diff} -> diff !== nil end)
+ |> case do
+ [] -> nil
+ diffs -> Enum.into(diffs, %{})
+ end
+
+ {key_ins, key_del, key_diff_map}
+ end
+end
diff --git a/lib/live_debugger/app/utils/term_parser.ex b/lib/live_debugger/app/utils/term_parser.ex
index 1cd32f479..6ef6e7910 100644
--- a/lib/live_debugger/app/utils/term_parser.ex
+++ b/lib/live_debugger/app/utils/term_parser.ex
@@ -18,21 +18,33 @@ defmodule LiveDebugger.App.Utils.TermParser do
@moduledoc """
Represents a node in the display tree.
+ - `id`: Unique identifier for the node.
- `kind`: The type of the node (e.g., "atom", "list", "map").
- `children`: A list of child nodes.
+ - `display?`: Whether the node should be displayed.
- `content`: Display elements that represent the content of the node when has no children or not expanded.
- `expanded_before`: Display elements shown before the node's children when expanded.
- `expanded_after`: Display elements shown after the node's children when expanded.
"""
- defstruct [:kind, :children, :content, :expanded_before, :expanded_after]
+ defstruct [:id, :kind, :children, :content, :expanded_before, :expanded_after, open?: true]
@type t :: %__MODULE__{
+ id: String.t(),
kind: String.t(),
- children: [TermNode.t()],
+ open?: boolean(),
+ children: [t()],
content: [DisplayElement.t()],
expanded_before: [DisplayElement.t()] | nil,
expanded_after: [DisplayElement.t()] | nil
}
+
+ @spec has_children?(t()) :: boolean()
+ def has_children?(%__MODULE__{children: []}), do: false
+ def has_children?(%__MODULE__{}), do: true
+
+ @spec children_number(t()) :: integer()
+ def children_number(%__MODULE__{children: nil}), do: 0
+ def children_number(%__MODULE__{children: children}), do: length(children)
end
@spec term_to_copy_string(term()) :: String.t()
@@ -48,15 +60,15 @@ defmodule LiveDebugger.App.Utils.TermParser do
@spec term_to_display_tree(term()) :: TermNode.t()
def term_to_display_tree(term) do
- to_node(term, [])
+ to_node(term, [], "root")
end
- @spec to_node(term(), [DisplayElement.t()]) :: TermNode.t()
- defp to_node(string, suffix) when is_binary(string) do
- leaf_node("binary", [green(inspect(string)) | suffix])
+ @spec to_node(term(), [DisplayElement.t()], String.t()) :: TermNode.t()
+ defp to_node(string, suffix, path) when is_binary(string) do
+ leaf_node("binary", [green(inspect(string)) | suffix], path)
end
- defp to_node(atom, suffix) when is_atom(atom) do
+ defp to_node(atom, suffix, path) when is_atom(atom) do
span =
if atom in [nil, true, false] do
magenta(inspect(atom))
@@ -64,45 +76,60 @@ defmodule LiveDebugger.App.Utils.TermParser do
blue(inspect(atom))
end
- leaf_node("atom", [span | suffix])
+ leaf_node("atom", [span | suffix], path)
end
- defp to_node(number, suffix) when is_number(number) do
- leaf_node("number", [blue(inspect(number)) | suffix])
+ defp to_node(number, suffix, path) when is_number(number) do
+ leaf_node("number", [blue(inspect(number)) | suffix], path)
end
- defp to_node({}, suffix) do
- leaf_node("tuple", [black("{}") | suffix])
+ defp to_node({}, suffix, path) do
+ leaf_node("tuple", [black("{}") | suffix], path)
end
- defp to_node(tuple, suffix) when is_tuple(tuple) do
+ defp to_node(tuple, suffix, path) when is_tuple(tuple) do
size = tuple_size(tuple)
- children = tuple |> Tuple.to_list() |> to_children(size)
- branch_node("tuple", [black("{...}") | suffix], children, [black("{")], [black("}") | suffix])
+ children = tuple |> Tuple.to_list() |> to_children(size, path)
+
+ branch_node(
+ "tuple",
+ [black("{...}") | suffix],
+ children,
+ [black("{")],
+ [black("}") | suffix],
+ path
+ )
end
- defp to_node([], suffix) do
- leaf_node("list", [black("[]") | suffix])
+ defp to_node([], suffix, path) do
+ leaf_node("list", [black("[]") | suffix], path)
end
- defp to_node(list, suffix) when is_list(list) do
+ defp to_node(list, suffix, path) when is_list(list) do
size = length(list)
children =
if Keyword.keyword?(list) do
- to_key_value_children(list, size)
+ to_key_value_children(list, size, path)
else
- to_children(list, size)
+ to_children(list, size, path)
end
- branch_node("list", [black("[...]") | suffix], children, [black("[")], [black("]") | suffix])
+ branch_node(
+ "list",
+ [black("[...]") | suffix],
+ children,
+ [black("[")],
+ [black("]") | suffix],
+ path
+ )
end
- defp to_node(%Regex{} = regex, suffix) do
- leaf_node("regex", [black(inspect(regex)) | suffix])
+ defp to_node(%Regex{} = regex, suffix, path) do
+ leaf_node("regex", [black(inspect(regex)) | suffix], path)
end
- defp to_node(%module{} = struct, suffix) when is_struct(struct) do
+ defp to_node(%module{} = struct, suffix, path) when is_struct(struct) do
content =
if Inspect.impl_for(struct) in [Inspect.Any, Inspect.Phoenix.LiveView.Socket] do
[black("%"), blue(inspect(module)), black("{...}") | suffix]
@@ -112,34 +139,43 @@ defmodule LiveDebugger.App.Utils.TermParser do
map = Map.from_struct(struct)
size = map_size(map)
- children = to_key_value_children(map, size)
+ children = to_key_value_children(map, size, path)
branch_node(
"struct",
content,
children,
[black("%"), blue(inspect(module)), black("{")],
- [black("}") | suffix]
+ [black("}") | suffix],
+ path
)
end
- defp to_node(%{} = map, suffix) when map_size(map) == 0 do
- leaf_node("map", [black("%{}") | suffix])
+ defp to_node(%{} = map, suffix, path) when map_size(map) == 0 do
+ leaf_node("map", [black("%{}") | suffix], path)
end
- defp to_node(map, suffix) when is_map(map) do
+ defp to_node(map, suffix, path) when is_map(map) do
size = map_size(map)
- children = map |> Enum.sort() |> to_key_value_children(size)
- branch_node("map", [black("%{...}") | suffix], children, [black("%{")], [black("}") | suffix])
+ children = map |> Enum.sort() |> to_key_value_children(size, path)
+
+ branch_node(
+ "map",
+ [black("%{...}") | suffix],
+ children,
+ [black("%{")],
+ [black("}") | suffix],
+ path
+ )
end
- defp to_node(other, suffix) do
- leaf_node("other", [black(inspect(other)) | suffix])
+ defp to_node(other, suffix, path) do
+ leaf_node("other", [black(inspect(other)) | suffix], path)
end
- defp to_key_value_node({key, value}, suffix) do
+ defp to_key_value_node({key, value}, suffix, path) do
{key_span, sep_span} =
- case to_node(key, []) do
+ case to_node(key, [], "#{path}.key") do
%TermNode{content: [%DisplayElement{text: ":" <> name} = span]} when is_atom(key) ->
{%{span | text: name <> ":"}, black(" ")}
@@ -151,7 +187,7 @@ defmodule LiveDebugger.App.Utils.TermParser do
black(" => ")}
end
- case to_node(value, suffix) do
+ case to_node(value, suffix, path) do
%TermNode{content: content, children: []} = node ->
%{node | content: [key_span, sep_span | content]}
@@ -164,15 +200,15 @@ defmodule LiveDebugger.App.Utils.TermParser do
end
end
- defp to_children(items, container_size) do
+ defp to_children(items, container_size, path) do
Enum.with_index(items, fn item, index ->
- to_node(item, suffix(index, container_size))
+ to_node(item, suffix(index, container_size), "#{path}.#{index}")
end)
end
- defp to_key_value_children(items, container_size) do
+ defp to_key_value_children(items, container_size, path) do
Enum.with_index(items, fn item, index ->
- to_key_value_node(item, suffix(index, container_size))
+ to_key_value_node(item, suffix(index, container_size), "#{path}.#{index}")
end)
end
@@ -184,8 +220,9 @@ defmodule LiveDebugger.App.Utils.TermParser do
end
end
- defp leaf_node(kind, content) do
+ defp leaf_node(kind, content, path) do
%TermNode{
+ id: path,
kind: kind,
content: content,
children: [],
@@ -194,8 +231,9 @@ defmodule LiveDebugger.App.Utils.TermParser do
}
end
- defp branch_node(kind, content, children, expanded_before, expanded_after) do
+ defp branch_node(kind, content, children, expanded_before, expanded_after, path) do
%TermNode{
+ id: path,
kind: kind,
content: content,
children: children,
diff --git a/lib/live_debugger/app/web/components.ex b/lib/live_debugger/app/web/components.ex
index 01f3a3430..d9783022a 100644
--- a/lib/live_debugger/app/web/components.ex
+++ b/lib/live_debugger/app/web/components.ex
@@ -165,6 +165,31 @@ defmodule LiveDebugger.App.Web.Components do
"""
end
+ attr(:open, :boolean, required: true)
+ attr(:class, :any, default: nil, doc: "CSS class for parent container")
+ attr(:label_class, :any, default: nil, doc: "CSS class for the label")
+ attr(:chevron_class, :any, default: nil, doc: "CSS class for the chevron icon")
+
+ attr(:rest, :global)
+
+ slot(:label, required: true)
+ slot(:inner_block, required: true)
+
+ def static_collapsible(assigns) do
+ ~H"""
+
+
+ <.icon
+ name="icon-chevron-right"
+ class={["shrink-0", if(@open, do: "rotate-90") | List.wrap(@chevron_class)]}
+ />
+ <%= render_slot(@label, @open) %>
+
+ <%= if(@open, do: render_slot(@inner_block)) %>
+
+ """
+ end
+
@doc """
Renders flash notices.
diff --git a/test/app/utils/term_differ_test.exs b/test/app/utils/term_differ_test.exs
new file mode 100644
index 000000000..7fec21713
--- /dev/null
+++ b/test/app/utils/term_differ_test.exs
@@ -0,0 +1,135 @@
+defmodule LiveDebugger.App.Utils.TermDifferTest do
+ use ExUnit.Case
+
+ alias LiveDebugger.App.Utils.TermDiffer
+
+ defmodule TestStruct1 do
+ defstruct [:a, :b, :c]
+ end
+
+ defmodule TestStruct2 do
+ defstruct [:a, :b, :c]
+ end
+
+ describe "diff/2" do
+ test "returns a diff between two primitive terms" do
+ assert TermDiffer.diff(1, 2) == %TermDiffer.Diff{
+ type: :primitive,
+ ins: [],
+ del: [],
+ diff: nil
+ }
+ end
+
+ test "returns a diff between two lists with ins and del" do
+ assert TermDiffer.diff([1, 2, 3, 4], [5, 1, 2, 3]) == %TermDiffer.Diff{
+ type: :list,
+ ins: [0],
+ del: [3],
+ diff: nil
+ }
+ end
+
+ test "returns ins and del when element in list changed" do
+ assert TermDiffer.diff([1, 2, 3, 4], [1, 2, 3, 5]) == %TermDiffer.Diff{
+ type: :list,
+ ins: [3],
+ del: [3],
+ diff: nil
+ }
+ end
+
+ test "returns a diff between two maps with different keys" do
+ assert TermDiffer.diff(%{a: 1, b: 2, c: 4}, %{a: 1, b: 2, d: 4}) == %TermDiffer.Diff{
+ type: :map,
+ ins: [:d],
+ del: [:c],
+ diff: nil
+ }
+ end
+
+ test "returns a diff between two maps with changed values" do
+ assert TermDiffer.diff(%{a: 1, b: 2, c: 4}, %{a: 1, b: 2, c: 5}) == %TermDiffer.Diff{
+ type: :map,
+ ins: [],
+ del: [],
+ diff: %{c: %TermDiffer.Diff{type: :primitive, ins: [], del: [], diff: nil}}
+ }
+ end
+
+ test "returns a diff between two tuples with different values" do
+ assert TermDiffer.diff({1, 2, 3, 4}, {2, 3, 5}) == %TermDiffer.Diff{
+ type: :tuple,
+ ins: [2],
+ del: [3, 0],
+ diff: nil
+ }
+ end
+
+ test "returns a diff between two structs with different values" do
+ assert TermDiffer.diff(%TestStruct1{a: 1, b: 2, c: 4}, %TestStruct1{a: 1, b: 2, c: 5}) ==
+ %TermDiffer.Diff{
+ type: :struct,
+ ins: [],
+ del: [],
+ diff: %{c: %TermDiffer.Diff{type: :primitive, ins: [], del: [], diff: nil}}
+ }
+ end
+
+ test "returns a primitive diff when the structs are not the same" do
+ assert TermDiffer.diff(%TestStruct1{a: 1, b: 2, c: 4}, %TestStruct2{a: 1, b: 2, c: 4}) ==
+ %TermDiffer.Diff{
+ type: :primitive,
+ ins: [],
+ del: [],
+ diff: nil
+ }
+ end
+
+ test "works properly with nested data types" do
+ term1 = %{
+ list: [1, 2, 3, 4],
+ map: %{a: 1, b: 2, c: 3},
+ struct: %TestStruct1{a: 1, b: 2, c: 3},
+ tuple: {1, 2, 3, 4},
+ primitive1: 1,
+ nested_map: %{
+ list: [1, 2],
+ tuple: {4, 4, 5, 4, 4}
+ }
+ }
+
+ term2 = %{
+ list: [1, 2, 3, 5],
+ map: %{a: 1, b: 2, c: 3},
+ struct: nil,
+ tuple: {1, 2, 3, 4},
+ primitive2: 1,
+ nested_map: %{
+ list: [1, 2],
+ tuple: {4, 4, 4, 4, 5}
+ }
+ }
+
+ assert %TermDiffer.Diff{
+ type: :map,
+ ins: [:primitive2],
+ del: [:primitive1],
+ diff: %{
+ list: %TermDiffer.Diff{
+ type: :list,
+ ins: [3],
+ del: [3]
+ },
+ struct: %TermDiffer.Diff{type: :primitive},
+ nested_map: %TermDiffer.Diff{
+ type: :map,
+ diff: %{
+ tuple: %TermDiffer.Diff{type: :tuple, ins: [4], del: [2]}
+ }
+ }
+ }
+ } = TermDiffer.diff(term1, term2)
+ end
+ end
+end