diff --git a/lib/live_debugger/app/debugger/node_state/web/components.ex b/lib/live_debugger/app/debugger/node_state/web/components.ex index ef7fd3603..65a8199a5 100644 --- a/lib/live_debugger/app/debugger/node_state/web/components.ex +++ b/lib/live_debugger/app/debugger/node_state/web/components.ex @@ -5,7 +5,7 @@ defmodule LiveDebugger.App.Debugger.NodeState.Web.Components do use LiveDebugger.App.Web, :component - alias LiveDebugger.App.Debugger.Web.Components.ElixirDisplay + alias LiveDebugger.App.Debugger.Web.LiveComponents.OptimizedElixirDisplay alias LiveDebugger.App.Utils.TermParser def loading(assigns) do @@ -41,12 +41,17 @@ defmodule LiveDebugger.App.Debugger.NodeState.Web.Components do
- + <.live_component + module={OptimizedElixirDisplay} + id="assigns-display" + node={TermParser.term_to_display_tree(@assigns)} + />
<.fullscreen id={@fullscreen_id} title="Assigns">
- diff --git a/lib/live_debugger/app/debugger/web/components/elixir_display.ex b/lib/live_debugger/app/debugger/web/components/elixir_display.ex index 740e44d14..a52e67bf7 100644 --- a/lib/live_debugger/app/debugger/web/components/elixir_display.ex +++ b/lib/live_debugger/app/debugger/web/components/elixir_display.ex @@ -6,8 +6,8 @@ defmodule LiveDebugger.App.Debugger.Web.Components.ElixirDisplay do use LiveDebugger.App.Web, :component - alias LiveDebugger.App.Utils.TermParser.DisplayElement alias LiveDebugger.App.Utils.TermParser.TermNode + alias LiveDebugger.App.Utils.TermParser.DisplayElement @max_auto_expand_size 6 @@ -22,8 +22,8 @@ defmodule LiveDebugger.App.Debugger.Web.Components.ElixirDisplay do def term(assigns) do assigns = assigns - |> assign(:expanded?, auto_expand?(assigns.node, assigns.level)) - |> assign(:has_children?, has_children?(assigns.node)) + |> assign(:auto_open?, auto_open?(assigns.node, assigns.level)) + |> assign(:has_children?, TermNode.has_children?(assigns.node)) ~H"""
@@ -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 %> +
  1. + <.term id={@id <> "-#{index}"} node={child} myself={@myself} /> +
  2. + <% 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