From c582e611ef290e237d863171fc40b36ea9b3e83d Mon Sep 17 00:00:00 2001 From: Alan Guzek Date: Thu, 9 Oct 2025 13:18:52 +0200 Subject: [PATCH 01/15] add static collapsible component --- lib/live_debugger/app/web/components.ex | 39 +++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/lib/live_debugger/app/web/components.ex b/lib/live_debugger/app/web/components.ex index 155bad397..673b087da 100644 --- a/lib/live_debugger/app/web/components.ex +++ b/lib/live_debugger/app/web/components.ex @@ -165,6 +165,45 @@ defmodule LiveDebugger.App.Web.Components do """ end + @doc """ + Static collapsible element. It doesn't perform any client-side actions. + + + ## Examples + + <.static_collapsible id="collapsible" open={true}> + <:label :let={open}> + <%= if(open, do: "Open", else: "Closed") %> + +
Content
+ + """ + + attr(:open, :boolean, required: true, doc: "State of the collapsible") + 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. From 1559653733280cd2cfc039231b15f715d1ae0a05 Mon Sep 17 00:00:00 2001 From: Alan Guzek Date: Thu, 9 Oct 2025 13:22:17 +0200 Subject: [PATCH 02/15] adjust TermNode --- .../debugger/web/components/elixir_display.ex | 29 +++---- lib/live_debugger/app/utils/term_parser.ex | 75 +++++++++++++++---- 2 files changed, 70 insertions(+), 34 deletions(-) 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 32346be24..8137be987 100644 --- a/lib/live_debugger/app/debugger/web/components/elixir_display.ex +++ b/lib/live_debugger/app/debugger/web/components/elixir_display.ex @@ -22,8 +22,9 @@ 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(:id, "#{assigns.id}-#{assigns.node.id}") + |> assign(:auto_open?, auto_open?(assigns.node, assigns.level)) + |> assign(:has_children?, TermNode.has_children?(assigns.node)) ~H"""
@@ -33,7 +34,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]" @@ -50,11 +51,9 @@ defmodule LiveDebugger.App.Debugger.Web.Components.ElixirDisplay do
    - <%= for {child, index} <- Enum.with_index(@node.children) do %> -
  1. - <.term id={@id <> "-#{index}"} node={child} level={@level + 1} /> -
  2. - <% end %> +
  3. + <.term id={@id} node={child} level={@level + 1} /> +
<.text_items items={@node.expanded_after} /> @@ -66,7 +65,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 +81,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/utils/term_parser.ex b/lib/live_debugger/app/utils/term_parser.ex index c3bf59c8e..13168710a 100644 --- a/lib/live_debugger/app/utils/term_parser.ex +++ b/lib/live_debugger/app/utils/term_parser.ex @@ -18,25 +18,34 @@ defmodule LiveDebugger.App.Utils.TermParser do @moduledoc """ Represents a node in the display tree. - - `id`: The id of the node (it represents the path to the node in the tree). + - `id`: The id of the node. It uses dot notation to represent the path to the node. + - `open?`: Whether the node is expanded - `kind`: The type of the node (e.g., :atom, :list, :map). - - `children`: A list of child nodes. + - `children`: A map of child nodes with keys (integer indices for lists/tuples, actual keys for maps). - `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 [:id, :kind, :children, :content, :expanded_before, :expanded_after] + defstruct [:id, :kind, :children, :content, :expanded_before, :expanded_after, open?: false] @type kind() :: :atom | :binary | :number | :tuple | :list | :map | :struct | :regex | :other @type t :: %__MODULE__{ id: String.t(), kind: kind(), - children: [t()], + open?: boolean(), + children: [{any(), 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: children}), do: length(children) end @spec term_to_copy_string(term()) :: String.t() @@ -55,6 +64,36 @@ defmodule LiveDebugger.App.Utils.TermParser do to_node(term, [], "root") end + @spec update_by_id(term(), String.t(), (TermNode.t() -> {:ok, TermNode.t()} | {:error, any()})) :: + {:ok, term()} | {:error, any()} + def update_by_id(term, "root", update_fn) do + update_fn.(term) + end + + def update_by_id(term, "root" <> _ = id, update_fn) do + ["root" | string_path] = String.split(id, ".") + path = string_path |> Enum.map(&String.to_integer/1) |> IO.inspect(label: "path") + update_by_path(term, path, update_fn) + end + + defp update_by_path(term, [index | path], update_fn) do + with {key, child} <- Enum.at(term.children, index), + {:ok, updated_child} <- update_by_path(child, path, update_fn) do + children = List.replace_at(term.children, index, {key, updated_child}) + {:ok, %TermNode{term | children: children}} + else + nil -> + {:error, :child_not_found} + + {:error, reason} -> + {:error, reason} + end + end + + defp update_by_path(term, [], update_fn) do + update_fn.(term) + end + @spec to_node(term(), [DisplayElement.t()], String.t()) :: TermNode.t() defp to_node(string, suffix, id_path) when is_binary(string) do leaf_node(id_path, :binary, [green(inspect(string)) | suffix]) @@ -121,7 +160,7 @@ defmodule LiveDebugger.App.Utils.TermParser do map = Map.from_struct(struct) size = map_size(map) - children = to_key_value_children(map, size, id_path) + children = map |> Map.to_list() |> to_key_value_children(size, id_path) branch_node( id_path, @@ -150,6 +189,8 @@ defmodule LiveDebugger.App.Utils.TermParser do leaf_node(id_path, :other, [black(inspect(other)) | suffix]) end + @spec to_key_value_node({any(), any()}, [DisplayElement.t()], String.t()) :: + {any(), TermNode.t()} defp to_key_value_node({key, value}, suffix, id_path) do {key_span, sep_span} = case to_node(key, [], "not_used_id_path") do @@ -164,25 +205,27 @@ defmodule LiveDebugger.App.Utils.TermParser do black(" => ")} end - case to_node(value, suffix, id_path) do - %TermNode{content: content, children: []} = node -> - %TermNode{node | content: [key_span, sep_span | content]} + node = to_node(value, suffix, id_path) + node = %TermNode{node | content: [key_span, sep_span | node.content]} - %TermNode{content: content, expanded_before: expanded_before} = node -> - %TermNode{ - node - | content: [key_span, sep_span | content], - expanded_before: [key_span, sep_span | expanded_before] - } - end + node = + if TermNode.has_children?(node) do + %TermNode{node | expanded_before: [key_span, sep_span | node.expanded_before]} + else + node + end + + {key, node} end + @spec to_children(list(), integer(), String.t()) :: [{integer(), TermNode.t()}] defp to_children(items, container_size, id_path) do Enum.with_index(items, fn item, index -> - to_node(item, suffix(index, container_size), "#{id_path}.#{index}") + {index, to_node(item, suffix(index, container_size), "#{id_path}.#{index}")} end) end + @spec to_key_value_children([{any(), any()}], integer(), String.t()) :: [{any(), TermNode.t()}] defp to_key_value_children(items, container_size, id_path) do Enum.with_index(items, fn item, index -> to_key_value_node(item, suffix(index, container_size), "#{id_path}.#{index}") From 76d39cb1e192c742197f64bc43c9253d4d7b598f Mon Sep 17 00:00:00 2001 From: Alan Guzek Date: Wed, 15 Oct 2025 11:24:17 +0200 Subject: [PATCH 03/15] Working still in progress --- .../app/debugger/node_state/queries.ex | 8 +- .../app/debugger/node_state/web/components.ex | 21 +- .../web/hook_components/assigns_display.ex | 103 +++++ .../web/hook_components/assigns_search.ex | 2 +- .../node_state/web/hooks/node_assigns.ex | 124 +++++ .../node_state/web/node_state_live.ex | 32 +- lib/live_debugger/app/utils/term_differ.ex | 8 + lib/live_debugger/app/utils/term_parser.ex | 436 +++++++++++++----- test/app/utils/term_parser_test.exs | 16 + 9 files changed, 602 insertions(+), 148 deletions(-) create mode 100644 lib/live_debugger/app/debugger/node_state/web/hook_components/assigns_display.ex create mode 100644 lib/live_debugger/app/debugger/node_state/web/hooks/node_assigns.ex diff --git a/lib/live_debugger/app/debugger/node_state/queries.ex b/lib/live_debugger/app/debugger/node_state/queries.ex index dc42ab4a8..70a404e85 100644 --- a/lib/live_debugger/app/debugger/node_state/queries.ex +++ b/lib/live_debugger/app/debugger/node_state/queries.ex @@ -9,11 +9,11 @@ defmodule LiveDebugger.App.Debugger.NodeState.Queries do alias LiveDebugger.App.Debugger.Structs.TreeNode @spec fetch_node_assigns(pid :: pid(), node_id :: TreeNode.id()) :: - {:ok, %{node_assigns: map()}} | {:error, term()} + {:ok, map()} | {:error, term()} def fetch_node_assigns(pid, node_id) when is_pid(node_id) do case fetch_node_state(pid) do {:ok, %LvState{socket: %{assigns: assigns}}} -> - {:ok, %{node_assigns: assigns}} + {:ok, assigns} {:error, reason} -> {:error, reason} @@ -48,8 +48,8 @@ defmodule LiveDebugger.App.Debugger.NodeState.Queries do nil -> {:error, "Component with CID #{cid} not found"} - component -> - {:ok, %{node_assigns: component.assigns}} + %{assigns: assigns} -> + {:ok, assigns} end end end 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 50e93aa78..b3c19ae08 100644 --- a/lib/live_debugger/app/debugger/node_state/web/components.ex +++ b/lib/live_debugger/app/debugger/node_state/web/components.ex @@ -5,9 +5,9 @@ defmodule LiveDebugger.App.Debugger.NodeState.Web.Components do use LiveDebugger.App.Web, :component - alias LiveDebugger.App.Debugger.Web.Components.ElixirDisplay - alias LiveDebugger.App.Utils.TermParser - alias LiveDebugger.App.Debugger.NodeState.Web.AssignsSearch + alias LiveDebugger.App.Debugger.NodeState.Web.HookComponents.AssignsDisplay + alias LiveDebugger.App.Debugger.NodeState.Web.HookComponents.AssignsSearch + alias LiveDebugger.App.Utils.TermParser.TermNode alias LiveDebugger.Utils.Memory def loading(assigns) do @@ -27,6 +27,8 @@ defmodule LiveDebugger.App.Debugger.NodeState.Web.Components do end attr(:assigns, :list, required: true) + attr(:term_node, TermNode, required: true) + attr(:copy_string, :string, required: true) attr(:fullscreen_id, :string, required: true) attr(:assigns_search_phrase, :string, default: "") @@ -40,11 +42,7 @@ defmodule LiveDebugger.App.Debugger.NodeState.Web.Components do assigns_search_phrase={@assigns_search_phrase} input_id="assigns-search-input" /> - <.copy_button - id="assigns-copy-button" - variant="icon-button" - value={TermParser.term_to_copy_string(@assigns)} - /> + <.copy_button id="assigns-copy-button" variant="icon-button" value={@copy_string} /> <.fullscreen_button id={@fullscreen_id} />
@@ -56,7 +54,7 @@ defmodule LiveDebugger.App.Debugger.NodeState.Web.Components do
<.assigns_size_label assigns={@assigns} id="display-container-size-label" />
- +
<.fullscreen id={@fullscreen_id} title="Assigns"> @@ -74,10 +72,7 @@ defmodule LiveDebugger.App.Debugger.NodeState.Web.Components do
<.assigns_size_label assigns={@assigns} id="display-fullscreen-size-label" />
- +
diff --git a/lib/live_debugger/app/debugger/node_state/web/hook_components/assigns_display.ex b/lib/live_debugger/app/debugger/node_state/web/hook_components/assigns_display.ex new file mode 100644 index 000000000..f4f0817c2 --- /dev/null +++ b/lib/live_debugger/app/debugger/node_state/web/hook_components/assigns_display.ex @@ -0,0 +1,103 @@ +defmodule LiveDebugger.App.Debugger.NodeState.Web.HookComponents.AssignsDisplay do + @moduledoc """ + 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, :hook_component + + alias LiveDebugger.App.Debugger.Web.Components.ElixirDisplay + alias LiveDebugger.App.Utils.TermParser.TermNode + alias LiveDebugger.App.Utils.TermParser + alias Phoenix.LiveView.AsyncResult + + @required_assigns [:node_assigns_info] + + @impl true + def init(socket) do + socket + |> check_assigns!(@required_assigns) + |> attach_hook(:assigns_display, :handle_event, &handle_event/3) + |> register_hook(:assigns_display) + end + + attr(:id, :string, required: true) + attr(:node, TermNode, required: true) + + @impl true + def render(assigns) do + ~H""" +
+ <.term id={@id} node={@node} /> +
+ """ + end + + defp handle_event("toggle_node", %{"id" => id}, socket) do + node_assigns_info = + with %AsyncResult{ok?: true, result: {node_assigns, term_node, copy_string}} <- + socket.assigns.node_assigns_info, + {:ok, updated_term_node} <- + TermParser.update_by_id(term_node, id, fn node -> + {:ok, %TermNode{node | open?: !node.open?}} + end) do + AsyncResult.ok({node_assigns, updated_term_node, copy_string}) + else + {:error, reason} -> + AsyncResult.failed(socket.assigns.node_assigns_info, reason) + + _ -> + socket.assigns.node_assigns_info + end + + socket + |> assign(:node_assigns_info, node_assigns_info) + |> halt() + end + + defp handle_event("toggle_node", _, socket), do: {:halt, socket} + defp handle_event(_, _, socket), do: {:cont, socket} + + attr(:id, :string, required: true) + attr(:node, TermNode, 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} + > + <:label :let={open}> + <%= if open do %> + + <% else %> + + <% end %> + +
    +
  1. + <.term id={@id} node={child} /> +
  2. +
+
+ +
+ + <% else %> +
+ +
+ <% end %> +
+ """ + end +end diff --git a/lib/live_debugger/app/debugger/node_state/web/hook_components/assigns_search.ex b/lib/live_debugger/app/debugger/node_state/web/hook_components/assigns_search.ex index 9adcacc65..3344639d6 100644 --- a/lib/live_debugger/app/debugger/node_state/web/hook_components/assigns_search.ex +++ b/lib/live_debugger/app/debugger/node_state/web/hook_components/assigns_search.ex @@ -1,4 +1,4 @@ -defmodule LiveDebugger.App.Debugger.NodeState.Web.AssignsSearch do +defmodule LiveDebugger.App.Debugger.NodeState.Web.HookComponents.AssignsSearch do @moduledoc """ This component is used to add search functionality for assigns. It produces `search` and `search-submit` events handled by hook added via `init/1`. diff --git a/lib/live_debugger/app/debugger/node_state/web/hooks/node_assigns.ex b/lib/live_debugger/app/debugger/node_state/web/hooks/node_assigns.ex new file mode 100644 index 000000000..35a2eb504 --- /dev/null +++ b/lib/live_debugger/app/debugger/node_state/web/hooks/node_assigns.ex @@ -0,0 +1,124 @@ +defmodule LiveDebugger.App.Debugger.NodeState.Web.Hooks.NodeAssigns do + @moduledoc """ + This hook is responsible for fetching assigns of specific node. + """ + + use LiveDebugger.App.Web, :hook + + alias Phoenix.LiveView.AsyncResult + alias LiveDebugger.App.Debugger.NodeState.Queries, as: NodeStateQueries + alias LiveDebugger.App.Utils.TermDiffer + alias LiveDebugger.App.Utils.TermDiffer.Diff + alias LiveDebugger.App.Utils.TermParser + + @required_assigns [ + :node_id, + :lv_process + ] + + @doc """ + Initializes the hook by attaching the hook to the socket and checking the required assigns. + """ + @spec init(Phoenix.LiveView.Socket.t()) :: Phoenix.LiveView.Socket.t() + def init(socket) do + socket + |> check_assigns!(@required_assigns) + |> attach_hook(:node_assigns, :handle_async, &handle_async/3) + |> register_hook(:node_assigns) + |> assign(:node_assigns_info, AsyncResult.loading()) + |> assign_async_node_assigns() + end + + @doc """ + Assigns the async node assigns to the socket. + + ## Options + + - `:reset` - If `true`, assigns won't diff current assigns with new ones. + """ + @spec assign_async_node_assigns(Phoenix.LiveView.Socket.t(), Keyword.t()) :: + Phoenix.LiveView.Socket.t() + def assign_async_node_assigns(socket, opts \\ []) + + def assign_async_node_assigns( + %{assigns: %{node_id: node_id, lv_process: %{pid: pid}}} = socket, + opts + ) + when not is_nil(node_id) do + node_assigns_info = + if Keyword.get(opts, :reset, false) do + AsyncResult.loading() + else + socket.assigns.node_assigns_info + end + + socket + |> assign(:node_assigns_info, node_assigns_info) + |> start_async(:fetch_node_assigns, fn -> + NodeStateQueries.fetch_node_assigns(pid, node_id) + end) + end + + def assign_async_node_assigns(socket, _) do + socket + |> assign(:node_assigns_info, AsyncResult.failed(%AsyncResult{}, :no_node_id)) + end + + defp handle_async( + :fetch_node_assigns, + {:ok, {:ok, node_assigns}}, + %{ + assigns: %{ + node_assigns_info: %AsyncResult{ok?: true, result: {old_assigns, old_term_node, _}} + } + } = + socket + ) do + case TermDiffer.diff(old_assigns, node_assigns) do + %Diff{type: :equal} -> + node_assigns_info = AsyncResult.ok(socket.assigns.node_assigns_info.result) + {:halt, assign(socket, :node_assigns_info, node_assigns_info)} + + diff -> + copy_string = TermParser.term_to_copy_string(node_assigns) + + case TermParser.update_by_diff(old_term_node, diff) do + {:ok, term_node} -> + socket + |> assign(:node_assigns_info, AsyncResult.ok({node_assigns, term_node, copy_string})) + |> halt() + + {:error, reason} -> + socket + |> assign( + :node_assigns_info, + AsyncResult.failed(socket.assigns.node_assigns_info, reason) + ) + |> halt() + end + end + end + + defp handle_async(:fetch_node_assigns, {:ok, {:ok, node_assigns}}, socket) do + term_node = TermParser.term_to_display_tree(node_assigns) + copy_string = TermParser.term_to_copy_string(node_assigns) + + socket + |> assign(:node_assigns_info, AsyncResult.ok({node_assigns, term_node, copy_string})) + |> halt() + end + + defp handle_async(:fetch_node_assigns, {:ok, {:error, reason}}, socket) do + socket + |> assign(:node_assigns_info, AsyncResult.failed(socket.assigns.node_assigns_info, reason)) + |> halt() + end + + defp handle_async(:fetch_node_assigns, {:exit, reason}, socket) do + socket + |> assign(:node_assigns_info, AsyncResult.failed(socket.assigns.node_assigns_info, reason)) + |> halt() + end + + defp handle_async(_, _, socket), do: {:cont, socket} +end diff --git a/lib/live_debugger/app/debugger/node_state/web/node_state_live.ex b/lib/live_debugger/app/debugger/node_state/web/node_state_live.ex index 1283d67ec..4904c7b74 100644 --- a/lib/live_debugger/app/debugger/node_state/web/node_state_live.ex +++ b/lib/live_debugger/app/debugger/node_state/web/node_state_live.ex @@ -6,12 +6,10 @@ defmodule LiveDebugger.App.Debugger.NodeState.Web.NodeStateLive do use LiveDebugger.App.Web, :live_view - alias Phoenix.LiveView.AsyncResult alias LiveDebugger.Structs.LvProcess + alias LiveDebugger.App.Debugger.NodeState.Web.Hooks + alias LiveDebugger.App.Debugger.NodeState.Web.HookComponents alias LiveDebugger.App.Debugger.NodeState.Web.Components, as: NodeStateComponents - alias LiveDebugger.App.Debugger.NodeState.Queries, as: NodeStateQueries - - alias LiveDebugger.App.Debugger.NodeState.Web.AssignsSearch alias LiveDebugger.Bus alias LiveDebugger.App.Debugger.Events.NodeIdParamChanged @@ -65,8 +63,9 @@ defmodule LiveDebugger.App.Debugger.NodeState.Web.NodeStateLive do |> assign(:lv_process, lv_process) |> assign(:node_id, node_id) |> assign(:assigns_search_phrase, "") - |> assign_async_node_assigns() - |> AssignsSearch.init() + |> Hooks.NodeAssigns.init() + |> HookComponents.AssignsDisplay.init() + |> HookComponents.AssignsSearch.init() |> ok() end @@ -74,7 +73,7 @@ defmodule LiveDebugger.App.Debugger.NodeState.Web.NodeStateLive do def render(assigns) do ~H"""
- <.async_result :let={node_assigns} assign={@node_assigns}> + <.async_result :let={{node_assigns, term_node, copy_string}} assign={@node_assigns_info}> <:loading> @@ -83,6 +82,8 @@ defmodule LiveDebugger.App.Debugger.NodeState.Web.NodeStateLive do assign(:node_id, node_id) - |> assign_async_node_assigns() + |> Hooks.NodeAssigns.assign_async_node_assigns(reset: true) |> noreply() end def handle_info(%StateChanged{}, socket) do socket - |> assign_async_node_assigns() + |> Hooks.NodeAssigns.assign_async_node_assigns() |> noreply() end def handle_info(_, socket), do: {:noreply, socket} - - defp assign_async_node_assigns( - %{assigns: %{node_id: node_id, lv_process: %{pid: pid}}} = socket - ) - when not is_nil(node_id) do - assign_async(socket, :node_assigns, fn -> - NodeStateQueries.fetch_node_assigns(pid, node_id) - end) - end - - defp assign_async_node_assigns(socket) do - assign(socket, :node, AsyncResult.failed(%AsyncResult{}, :no_node_id)) - end end diff --git a/lib/live_debugger/app/utils/term_differ.ex b/lib/live_debugger/app/utils/term_differ.ex index bbf4f449c..d82a63d48 100644 --- a/lib/live_debugger/app/utils/term_differ.ex +++ b/lib/live_debugger/app/utils/term_differ.ex @@ -72,6 +72,14 @@ defmodule LiveDebugger.App.Utils.TermDiffer do """ def primitive_key, do: @primitive_key + @doc """ + Returns the new value for `primitive` type. + """ + @spec primitive_new_value(Diff.t()) :: term() + def primitive_new_value(%Diff{type: :primitive, ins: %{@primitive_key => new_value}}) do + new_value + end + @doc """ Calculates the diff between two terms. """ diff --git a/lib/live_debugger/app/utils/term_parser.ex b/lib/live_debugger/app/utils/term_parser.ex index 13168710a..c3c475932 100644 --- a/lib/live_debugger/app/utils/term_parser.ex +++ b/lib/live_debugger/app/utils/term_parser.ex @@ -4,6 +4,11 @@ defmodule LiveDebugger.App.Utils.TermParser do Based on [Kino.Tree](https://github.com/livebook-dev/kino/blob/main/lib/kino/tree.ex) """ + alias LiveDebugger.App.Utils.TermDiffer.Diff + alias LiveDebugger.App.Utils.TermDiffer + + require Logger + defmodule DisplayElement do @moduledoc false defstruct [:text, color: nil] @@ -12,6 +17,18 @@ defmodule LiveDebugger.App.Utils.TermParser do text: String.t(), color: String.t() | nil } + + @spec blue(String.t()) :: t() + def blue(text), do: %__MODULE__{text: text, color: "text-code-1"} + + @spec black(String.t()) :: t() + def black(text), do: %__MODULE__{text: text, color: "text-code-2"} + + @spec magenta(String.t()) :: t() + def magenta(text), do: %__MODULE__{text: text, color: "text-code-3"} + + @spec green(String.t()) :: t() + def green(text), do: %__MODULE__{text: text, color: "text-code-4"} end defmodule TermNode do @@ -40,14 +57,77 @@ defmodule LiveDebugger.App.Utils.TermParser do expanded_after: [DisplayElement.t()] | nil } + @type ok_error() :: {:ok, t()} | {:error, any()} + @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: children}), do: length(children) + + @spec add_suffix(t(), [DisplayElement.t()]) :: t() + def add_suffix( + %__MODULE__{content: content, expanded_after: expanded_after} = term_node, + suffix + ) + when is_list(suffix) do + content = content ++ suffix + expanded_after = expanded_after ++ suffix + %__MODULE__{term_node | content: content, expanded_after: expanded_after} + end + + @spec remove_suffix(t()) :: t() + def remove_suffix(%__MODULE__{content: content, expanded_after: expanded_after} = term_node) do + content = content |> Enum.reverse() |> tl() |> Enum.reverse() + expanded_after = expanded_after |> Enum.reverse() |> tl() |> Enum.reverse() + + %__MODULE__{term_node | content: content, expanded_after: expanded_after} + end + + @spec add_prefix(t(), [DisplayElement.t()]) :: t() + def add_prefix( + %__MODULE__{content: content, expanded_before: expanded_before} = term_node, + prefix + ) + when is_list(prefix) do + content = prefix ++ content + expanded_before = prefix ++ expanded_before + %__MODULE__{term_node | content: content, expanded_before: expanded_before} + end + + @spec remove_child(t(), any()) :: {:ok, t()} | {:error, any()} + def remove_child(%__MODULE__{children: children} = term, key) do + children + |> Enum.find_index(fn {child_key, _} -> child_key == key end) + |> case do + nil -> + {:error, :child_not_found} + + index -> + children = List.delete_at(children, index) + {:ok, %TermNode{term | children: children}} + end + end + + @spec update_child(t(), any(), (t() -> t())) :: {:ok, t()} | {:error, any()} + def update_child(%__MODULE__{children: children} = term, key, update_fn) do + children + |> Enum.with_index() + |> Enum.filter(fn {{child_key, _}, _} -> child_key == key end) + |> case do + [{{^key, child}, index}] -> + children = List.replace_at(children, index, {key, update_fn.(child)}) + {:ok, %TermNode{term | children: children}} + + _ -> + {:error, :child_not_found} + end + end end + @comma_suffix DisplayElement.black(",") + @spec term_to_copy_string(term()) :: String.t() def term_to_copy_string(term) do term @@ -59,23 +139,168 @@ defmodule LiveDebugger.App.Utils.TermParser do |> String.replace(~r/#.+?<.*?>/, &"\"#{&1}\"") end + @doc """ + It creates an indexed TermNode tree with comma suffixes and first element opened. + """ @spec term_to_display_tree(term()) :: TermNode.t() def term_to_display_tree(term) do - to_node(term, [], "root") + node = + term + |> to_node() + |> index_term_node() + |> update_comma_suffixes() + + %TermNode{node | open?: true} end - @spec update_by_id(term(), String.t(), (TermNode.t() -> {:ok, TermNode.t()} | {:error, any()})) :: - {:ok, term()} | {:error, any()} - def update_by_id(term, "root", update_fn) do - update_fn.(term) + @spec update_by_id(TermNode.t(), String.t(), (TermNode.t() -> TermNode.ok_error())) :: + TermNode.ok_error() + def update_by_id(term_node, "root", update_fn) do + update_fn.(term_node) end - def update_by_id(term, "root" <> _ = id, update_fn) do + def update_by_id(term_node, "root" <> _ = id, update_fn) do ["root" | string_path] = String.split(id, ".") - path = string_path |> Enum.map(&String.to_integer/1) |> IO.inspect(label: "path") - update_by_path(term, path, update_fn) + path = string_path |> Enum.map(&String.to_integer/1) + update_by_path(term_node, path, update_fn) + end + + @spec update_by_diff(TermNode.t(), Diff.t()) :: TermNode.ok_error() + def update_by_diff(term_node, diff) do + term_node = + term_node + |> update_by_diff!(diff) + |> index_term_node() + |> update_comma_suffixes() + + {:ok, term_node} + rescue + error -> + {:error, "Invalid diff or term node: #{inspect(error)}"} + end + + @spec update_by_diff!(TermNode.t(), Diff.t(), Keyword.t()) :: TermNode.t() + defp update_by_diff!(term_node, diff, opts \\ []) + + defp update_by_diff!(_, %Diff{type: :primitive} = diff, opts) do + term = TermDiffer.primitive_new_value(diff) + + maybe_to_key_value_node(term, opts) + end + + defp update_by_diff!(term_node, %Diff{type: type, ins: ins, del: del}, _opts) + when type in [:list, :tuple] do + term_node = + Enum.reduce(del, term_node, fn {index, _}, term_node -> + {:ok, term_node} = TermNode.remove_child(term_node, index) + term_node + end) + + term_node = + Enum.reduce(ins, term_node, fn {index, term}, term_node -> + children = List.insert_at(term_node.children, index, {index, to_node(term)}) + %TermNode{term_node | children: children} + end) + + term_node + end + + defp update_by_diff!(term_node, %Diff{type: :struct, diff: diff}, _opts) do + Enum.reduce(diff, term_node, fn {key, child_diff}, term_node -> + {:ok, term_node} = + TermNode.update_child(term_node, key, fn child -> + update_by_diff!(child, child_diff, key: key) + end) + + term_node + end) + end + + defp update_by_diff!(term_node, %Diff{type: :map, ins: ins, del: del, diff: diff}, _opts) do + term_node = + Enum.reduce(del, term_node, fn {key, _}, term_node -> + {:ok, term_node} = TermNode.remove_child(term_node, key) + term_node + end) + + child_keys = term_node.children |> Enum.map(fn {key, _} -> key end) + + {term_node, _} = + Enum.reduce(ins, {term_node, child_keys}, fn {key, term}, {term_node, child_keys} -> + child_term_node = maybe_to_key_value_node(term, key: key) + child_keys = Enum.sort(child_keys ++ [key]) + index = Enum.find_index(child_keys, &(&1 == key)) + children = List.insert_at(term_node.children, index, child_term_node) + {%TermNode{term_node | children: children}, child_keys} + end) + + Enum.reduce(diff, term_node, fn {key, child_diff}, term_node -> + {:ok, term_node} = + TermNode.update_child(term_node, key, fn child -> + update_by_diff!(child, child_diff, key: key) + end) + + term_node + end) + end + + defp maybe_to_key_value_node(term, opts) do + case Keyword.get(opts, :key, nil) do + nil -> + to_node(term) + + key -> + {key, term} + |> to_key_value_node() + |> elem(1) + end end + @spec index_term_node(TermNode.t()) :: TermNode.t() + defp index_term_node(%TermNode{children: children} = term_node, id_path \\ "root") do + new_children = + children + |> Enum.with_index() + |> Enum.map(fn {{key, child}, idx} -> + {key, index_term_node(child, "#{id_path}.#{idx}")} + end) + + %TermNode{term_node | children: new_children, id: id_path} + end + + @spec update_comma_suffixes(TermNode.t()) :: TermNode.t() + defp update_comma_suffixes(%TermNode{children: []} = term_node), do: term_node + + defp update_comma_suffixes(%TermNode{children: children} = term_node) do + size = length(children) + + children = + Enum.with_index(children, fn + {key, child}, index -> + child = update_comma_suffixes(child) + + last_child? = index == size - 1 + + child = + case {last_child?, has_comma_suffix?(child)} do + {true, true} -> + child |> TermNode.remove_suffix() + + {false, false} -> + child |> TermNode.add_suffix([@comma_suffix]) + + _ -> + child + end + + {key, child} + end) + + %TermNode{term_node | children: children} + end + + @spec update_by_path(TermNode.t(), [integer()], (TermNode.t() -> TermNode.ok_error())) :: + TermNode.ok_error() defp update_by_path(term, [index | path], update_fn) do with {key, child} <- Enum.at(term.children, index), {:ok, updated_child} <- update_by_path(child, path, update_fn) do @@ -94,177 +319,172 @@ defmodule LiveDebugger.App.Utils.TermParser do update_fn.(term) end - @spec to_node(term(), [DisplayElement.t()], String.t()) :: TermNode.t() - defp to_node(string, suffix, id_path) when is_binary(string) do - leaf_node(id_path, :binary, [green(inspect(string)) | suffix]) + @spec to_node(term()) :: TermNode.t() + defp to_node(string) when is_binary(string) do + node(:binary, [DisplayElement.green(inspect(string))]) end - defp to_node(atom, suffix, id_path) when is_atom(atom) do + defp to_node(atom) when is_atom(atom) do span = if atom in [nil, true, false] do - magenta(inspect(atom)) + DisplayElement.magenta(inspect(atom)) else - blue(inspect(atom)) + DisplayElement.blue(inspect(atom)) end - leaf_node(id_path, :atom, [span | suffix]) + node(:atom, [span]) end - defp to_node(number, suffix, id_path) when is_number(number) do - leaf_node(id_path, :number, [blue(inspect(number)) | suffix]) + defp to_node(number) when is_number(number) do + node(:number, [DisplayElement.blue(inspect(number))]) end - defp to_node({}, suffix, id_path) do - leaf_node(id_path, :tuple, [black("{}") | suffix]) + defp to_node({}) do + node(:tuple, [DisplayElement.black("{}")]) end - defp to_node(tuple, suffix, id_path) when is_tuple(tuple) do - size = tuple_size(tuple) - children = tuple |> Tuple.to_list() |> to_children(size, id_path) + defp to_node(tuple) when is_tuple(tuple) do + children = tuple |> Tuple.to_list() |> to_children() - branch_node(id_path, :tuple, [black("{...}") | suffix], children, [black("{")], [ - black("}") | suffix - ]) + node(:tuple, [DisplayElement.black("{...}")], + children: children, + expanded_before: [DisplayElement.black("{")], + expanded_after: [DisplayElement.black("}")] + ) end - defp to_node([], suffix, id_path) do - leaf_node(id_path, :list, [black("[]") | suffix]) + defp to_node([]) do + node(:list, [DisplayElement.black("[]")]) end - defp to_node(list, suffix, id_path) when is_list(list) do - size = length(list) - + defp to_node(list) when is_list(list) do children = if Keyword.keyword?(list) do - to_key_value_children(list, size, id_path) + to_key_value_children(list) else - to_children(list, size, id_path) + to_children(list) end - branch_node(id_path, :list, [black("[...]") | suffix], children, [black("[")], [ - black("]") | suffix - ]) + node(:list, [DisplayElement.black("[...]")], + children: children, + expanded_before: [DisplayElement.black("[")], + expanded_after: [DisplayElement.black("]")] + ) end - defp to_node(%Regex{} = regex, suffix, id_path) do - leaf_node(id_path, :regex, [black(inspect(regex)) | suffix]) + defp to_node(%Regex{} = regex) do + node(:regex, [DisplayElement.black(inspect(regex))]) end - defp to_node(%module{} = struct, suffix, id_path) when is_struct(struct) do + defp to_node(%module{} = struct) 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] + [ + DisplayElement.black("%"), + DisplayElement.blue(inspect(module)), + DisplayElement.black("{...}") + ] else - [black(inspect(struct)) | suffix] + [DisplayElement.black(inspect(struct))] end - map = Map.from_struct(struct) - size = map_size(map) - children = map |> Map.to_list() |> to_key_value_children(size, id_path) + children = + struct + |> Map.from_struct() + |> Map.to_list() + |> to_key_value_children() - branch_node( - id_path, + node( :struct, content, - children, - [black("%"), blue(inspect(module)), black("{")], - [black("}") | suffix] + children: children, + expanded_before: [ + DisplayElement.black("%"), + DisplayElement.blue(inspect(module)), + DisplayElement.black("{") + ], + expanded_after: [DisplayElement.black("}")] ) end - defp to_node(%{} = map, suffix, id_path) when map_size(map) == 0 do - leaf_node(id_path, :map, [black("%{}") | suffix]) + defp to_node(%{} = map) when map_size(map) == 0 do + node(:map, [DisplayElement.black("%{}")]) end - defp to_node(map, suffix, id_path) when is_map(map) do - size = map_size(map) - children = map |> Enum.sort() |> to_key_value_children(size, id_path) + defp to_node(map) when is_map(map) do + children = map |> Enum.sort() |> to_key_value_children() - branch_node(id_path, :map, [black("%{...}") | suffix], children, [black("%{")], [ - black("}") | suffix - ]) + node(:map, [DisplayElement.black("%{...}")], + children: children, + expanded_before: [DisplayElement.black("%{")], + expanded_after: [DisplayElement.black("}")] + ) end - defp to_node(other, suffix, id_path) do - leaf_node(id_path, :other, [black(inspect(other)) | suffix]) + defp to_node(other) do + node(:other, [DisplayElement.black(inspect(other))]) end - @spec to_key_value_node({any(), any()}, [DisplayElement.t()], String.t()) :: - {any(), TermNode.t()} - defp to_key_value_node({key, value}, suffix, id_path) do + defp to_key_value_node({key, value}) do {key_span, sep_span} = - case to_node(key, [], "not_used_id_path") do + case to_node(key) do %TermNode{content: [%DisplayElement{text: ":" <> name} = span]} when is_atom(key) -> - {%{span | text: name <> ":"}, black(" ")} + {%{span | text: name <> ":"}, DisplayElement.black(" ")} %TermNode{content: [span]} -> - {%{span | text: inspect(key, width: :infinity)}, black(" => ")} + {%{span | text: inspect(key, width: :infinity)}, DisplayElement.black(" => ")} %TermNode{content: _content} -> {%DisplayElement{text: inspect(key, width: :infinity), color: "text-code-1"}, - black(" => ")} + DisplayElement.black(" => ")} end - node = to_node(value, suffix, id_path) - node = %TermNode{node | content: [key_span, sep_span | node.content]} - - node = - if TermNode.has_children?(node) do - %TermNode{node | expanded_before: [key_span, sep_span | node.expanded_before]} - else - node - end + node = value |> to_node() |> TermNode.add_prefix([key_span, sep_span]) {key, node} end - @spec to_children(list(), integer(), String.t()) :: [{integer(), TermNode.t()}] - defp to_children(items, container_size, id_path) do + defp to_children(items) when is_list(items) do Enum.with_index(items, fn item, index -> - {index, to_node(item, suffix(index, container_size), "#{id_path}.#{index}")} + {index, to_node(item)} end) end - @spec to_key_value_children([{any(), any()}], integer(), String.t()) :: [{any(), TermNode.t()}] - defp to_key_value_children(items, container_size, id_path) do - Enum.with_index(items, fn item, index -> - to_key_value_node(item, suffix(index, container_size), "#{id_path}.#{index}") - end) - end - - defp suffix(index, container_size) do - if index != container_size - 1 do - [black(",")] - else - [] - end + defp to_key_value_children(items) when is_list(items) do + Enum.map(items, &to_key_value_node/1) end - defp leaf_node(id_path, kind, content) when is_binary(id_path) and is_atom(kind) do - %TermNode{ - id: id_path, - kind: kind, - content: content, - children: [], - expanded_before: nil, - expanded_after: nil - } - end + defp node(kind, content, opts \\ []) when is_atom(kind) do + children = Keyword.get(opts, :children, []) + expanded_before = Keyword.get(opts, :expanded_before, []) + expanded_after = Keyword.get(opts, :expanded_after, []) + open? = Keyword.get(opts, :open?, false) + id = Keyword.get(opts, :id, "not_indexed") - defp branch_node(id_path, kind, content, children, expanded_before, expanded_after) - when is_binary(id_path) and is_atom(kind) do %TermNode{ - id: id_path, + id: id, kind: kind, content: content, children: children, expanded_before: expanded_before, - expanded_after: expanded_after + expanded_after: expanded_after, + open?: open? } end - defp blue(text), do: %DisplayElement{text: text, color: "text-code-1"} - defp black(text), do: %DisplayElement{text: text, color: "text-code-2"} - defp magenta(text), do: %DisplayElement{text: text, color: "text-code-3"} - defp green(text), do: %DisplayElement{text: text, color: "text-code-4"} + defp has_comma_suffix?(%TermNode{content: content, expanded_after: expanded_after}) do + content? = last_item_equal?(content, @comma_suffix) + expanded_after? = last_item_equal?(expanded_after, @comma_suffix) + + if content? != expanded_after?, + do: raise("Content and expanded_after must have the same comma suffix") + + content? + end + + defp last_item_equal?([_ | _] = items, item) do + items |> Enum.reverse() |> hd() |> Kernel.==(item) + end + + defp last_item_equal?([], _), do: false end diff --git a/test/app/utils/term_parser_test.exs b/test/app/utils/term_parser_test.exs index fb40c48d4..fdba92d5f 100644 --- a/test/app/utils/term_parser_test.exs +++ b/test/app/utils/term_parser_test.exs @@ -16,6 +16,7 @@ defmodule LiveDebugger.App.Utils.TermParserTest do expected = %TermNode{ id: "root", kind: :binary, + open?: true, children: [], content: [%DisplayElement{text: "\"Hello, World!\"", color: "text-code-4"}], expanded_before: nil, @@ -31,6 +32,7 @@ defmodule LiveDebugger.App.Utils.TermParserTest do expected = %TermNode{ id: "root", kind: :atom, + open?: true, children: [], content: [%DisplayElement{text: ":hello", color: "text-code-1"}], expanded_before: nil, @@ -46,6 +48,7 @@ defmodule LiveDebugger.App.Utils.TermParserTest do expected = %TermNode{ id: "root", kind: :number, + open?: true, children: [], content: [%DisplayElement{text: "42", color: "text-code-1"}], expanded_before: nil, @@ -65,6 +68,7 @@ defmodule LiveDebugger.App.Utils.TermParserTest do %TermNode{ id: "root.0", kind: :atom, + open?: true, children: [], content: [ %DisplayElement{text: ":ok", color: "text-code-1"}, @@ -76,6 +80,7 @@ defmodule LiveDebugger.App.Utils.TermParserTest do %TermNode{ id: "root.1", kind: :binary, + open?: true, children: [], content: [%DisplayElement{text: "\"Hello\"", color: "text-code-4"}], expanded_before: nil, @@ -96,6 +101,7 @@ defmodule LiveDebugger.App.Utils.TermParserTest do expected = %TermNode{ id: "root", kind: :tuple, + open?: true, children: [], content: [%DisplayElement{text: "{}", color: "text-code-2"}], expanded_before: nil, @@ -111,6 +117,7 @@ defmodule LiveDebugger.App.Utils.TermParserTest do expected = %TermNode{ id: "root", kind: :list, + open?: true, children: [ %TermNode{ id: "root.0", @@ -157,6 +164,7 @@ defmodule LiveDebugger.App.Utils.TermParserTest do expected = %TermNode{ id: "root", kind: :list, + open?: true, children: [], content: [%DisplayElement{text: "[]", color: "text-code-2"}], expanded_before: nil, @@ -172,6 +180,7 @@ defmodule LiveDebugger.App.Utils.TermParserTest do expected = %TermNode{ id: "root", kind: :regex, + open?: true, children: [], content: [%DisplayElement{text: "~r/hello/", color: "text-code-2"}], expanded_before: nil, @@ -188,6 +197,7 @@ defmodule LiveDebugger.App.Utils.TermParserTest do expected = %TermNode{ id: "root", kind: :atom, + open?: true, children: [], content: [%DisplayElement{text: inspect(term), color: "text-code-3"}], expanded_before: nil, @@ -204,6 +214,7 @@ defmodule LiveDebugger.App.Utils.TermParserTest do expected = %TermNode{ id: "root", kind: :map, + open?: true, children: [], content: [%DisplayElement{text: "%{}", color: "text-code-2"}], expanded_before: nil, @@ -221,6 +232,7 @@ defmodule LiveDebugger.App.Utils.TermParserTest do expected = %TermNode{ id: "root", kind: :struct, + open?: true, children: [ %TermNode{ id: "root.0", @@ -269,6 +281,8 @@ defmodule LiveDebugger.App.Utils.TermParserTest do expected = %TermNode{ id: "root", + kind: :struct, + open?: true, children: [ %TermNode{ id: "root.0", @@ -345,6 +359,7 @@ defmodule LiveDebugger.App.Utils.TermParserTest do expected = %TermNode{ id: "root", kind: :map, + open?: true, children: [ %TermNode{ id: "root.0", @@ -402,6 +417,7 @@ defmodule LiveDebugger.App.Utils.TermParserTest do expected = %TermNode{ id: "root", kind: :map, + open?: true, children: [ %TermNode{ id: "root.0", From c272c6fc65171d5ea74c286298e5396335d202af Mon Sep 17 00:00:00 2001 From: Alan Guzek Date: Wed, 15 Oct 2025 11:47:33 +0200 Subject: [PATCH 04/15] remove id from AssignsDisplay.term --- .../node_state/web/hook_components/assigns_display.ex | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/lib/live_debugger/app/debugger/node_state/web/hook_components/assigns_display.ex b/lib/live_debugger/app/debugger/node_state/web/hook_components/assigns_display.ex index f4f0817c2..833c13798 100644 --- a/lib/live_debugger/app/debugger/node_state/web/hook_components/assigns_display.ex +++ b/lib/live_debugger/app/debugger/node_state/web/hook_components/assigns_display.ex @@ -28,7 +28,7 @@ defmodule LiveDebugger.App.Debugger.NodeState.Web.HookComponents.AssignsDisplay def render(assigns) do ~H"""
- <.term id={@id} node={@node} /> + <.term node={@node} />
""" end @@ -58,7 +58,6 @@ defmodule LiveDebugger.App.Debugger.NodeState.Web.HookComponents.AssignsDisplay defp handle_event("toggle_node", _, socket), do: {:halt, socket} defp handle_event(_, _, socket), do: {:cont, socket} - attr(:id, :string, required: true) attr(:node, TermNode, required: true) defp term(assigns) do @@ -85,7 +84,7 @@ defmodule LiveDebugger.App.Debugger.NodeState.Web.HookComponents.AssignsDisplay
  1. - <.term id={@id} node={child} /> + <.term node={child} />
From d9b53dfaea924a63300052c3fb946425fb3d7a5b Mon Sep 17 00:00:00 2001 From: Alan Guzek Date: Wed, 15 Oct 2025 13:13:31 +0200 Subject: [PATCH 05/15] clear modules --- .../app/debugger/node_state/web/components.ex | 2 +- .../web/hook_components/assigns_display.ex | 2 +- .../debugger/web/components/elixir_display.ex | 4 +- lib/live_debugger/app/utils/term_node.ex | 140 +++++++ lib/live_debugger/app/utils/term_parser.ex | 378 +++++++----------- 5 files changed, 295 insertions(+), 231 deletions(-) create mode 100644 lib/live_debugger/app/utils/term_node.ex 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 b3c19ae08..7ec49586f 100644 --- a/lib/live_debugger/app/debugger/node_state/web/components.ex +++ b/lib/live_debugger/app/debugger/node_state/web/components.ex @@ -7,7 +7,7 @@ defmodule LiveDebugger.App.Debugger.NodeState.Web.Components do alias LiveDebugger.App.Debugger.NodeState.Web.HookComponents.AssignsDisplay alias LiveDebugger.App.Debugger.NodeState.Web.HookComponents.AssignsSearch - alias LiveDebugger.App.Utils.TermParser.TermNode + alias LiveDebugger.App.Utils.TermNode alias LiveDebugger.Utils.Memory def loading(assigns) do diff --git a/lib/live_debugger/app/debugger/node_state/web/hook_components/assigns_display.ex b/lib/live_debugger/app/debugger/node_state/web/hook_components/assigns_display.ex index 833c13798..a3f9fdaae 100644 --- a/lib/live_debugger/app/debugger/node_state/web/hook_components/assigns_display.ex +++ b/lib/live_debugger/app/debugger/node_state/web/hook_components/assigns_display.ex @@ -7,7 +7,7 @@ defmodule LiveDebugger.App.Debugger.NodeState.Web.HookComponents.AssignsDisplay use LiveDebugger.App.Web, :hook_component alias LiveDebugger.App.Debugger.Web.Components.ElixirDisplay - alias LiveDebugger.App.Utils.TermParser.TermNode + alias LiveDebugger.App.Utils.TermNode alias LiveDebugger.App.Utils.TermParser alias Phoenix.LiveView.AsyncResult 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 8137be987..4386ce15a 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.TermNode.DisplayElement + alias LiveDebugger.App.Utils.TermNode @max_auto_expand_size 6 diff --git a/lib/live_debugger/app/utils/term_node.ex b/lib/live_debugger/app/utils/term_node.ex new file mode 100644 index 000000000..916182f57 --- /dev/null +++ b/lib/live_debugger/app/utils/term_node.ex @@ -0,0 +1,140 @@ +defmodule LiveDebugger.App.Utils.TermNode do + @moduledoc """ + Represents a node in the display tree. + Based on [Kino.Tree](https://github.com/livebook-dev/kino/blob/main/lib/kino/tree.ex) + + - `id`: The id of the node. It uses dot notation to represent the path to the node. + - `open?`: Whether the node is expanded + - `kind`: The type of the node (e.g., :atom, :list, :map). + - `children`: A map of child nodes with keys (integer indices for lists/tuples, actual keys for maps). + - `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. + """ + + defmodule DisplayElement do + @moduledoc false + defstruct [:text, color: nil] + + @type t :: %__MODULE__{ + text: String.t(), + color: String.t() | nil + } + + @spec blue(String.t()) :: t() + def blue(text), do: %__MODULE__{text: text, color: "text-code-1"} + + @spec black(String.t()) :: t() + def black(text), do: %__MODULE__{text: text, color: "text-code-2"} + + @spec magenta(String.t()) :: t() + def magenta(text), do: %__MODULE__{text: text, color: "text-code-3"} + + @spec green(String.t()) :: t() + def green(text), do: %__MODULE__{text: text, color: "text-code-4"} + end + + defstruct [:id, :kind, :children, :content, :expanded_before, :expanded_after, open?: false] + + @type kind() :: :atom | :binary | :number | :tuple | :list | :map | :struct | :regex | :other + + @type t :: %__MODULE__{ + id: String.t(), + kind: kind(), + open?: boolean(), + children: [{any(), t()}], + content: [DisplayElement.t()], + expanded_before: [DisplayElement.t()] | nil, + expanded_after: [DisplayElement.t()] | nil + } + + @type ok_error() :: {:ok, t()} | {:error, any()} + + @spec new(kind(), [DisplayElement.t()], Keyword.t()) :: t() + def new(kind, content, args \\ []) do + children = Keyword.get(args, :children, []) + expanded_before = Keyword.get(args, :expanded_before, []) + expanded_after = Keyword.get(args, :expanded_after, []) + open? = Keyword.get(args, :open?, false) + id = Keyword.get(args, :id, "not_indexed") + + %__MODULE__{ + id: id, + kind: kind, + content: content, + children: children, + expanded_before: expanded_before, + expanded_after: expanded_after, + open?: open? + } + end + + @spec comma_suffix() :: DisplayElement.t() + def comma_suffix(), do: DisplayElement.black(",") + + @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: children}), do: length(children) + + @spec add_suffix(t(), [DisplayElement.t()]) :: t() + def add_suffix( + %__MODULE__{content: content, expanded_after: expanded_after} = term_node, + suffix + ) + when is_list(suffix) do + content = content ++ suffix + expanded_after = expanded_after ++ suffix + %__MODULE__{term_node | content: content, expanded_after: expanded_after} + end + + @spec remove_suffix(t()) :: t() + def remove_suffix(%__MODULE__{content: content, expanded_after: expanded_after} = term_node) do + content = content |> Enum.reverse() |> tl() |> Enum.reverse() + expanded_after = expanded_after |> Enum.reverse() |> tl() |> Enum.reverse() + + %__MODULE__{term_node | content: content, expanded_after: expanded_after} + end + + @spec add_prefix(t(), [DisplayElement.t()]) :: t() + def add_prefix( + %__MODULE__{content: content, expanded_before: expanded_before} = term_node, + prefix + ) + when is_list(prefix) do + content = prefix ++ content + expanded_before = prefix ++ expanded_before + %__MODULE__{term_node | content: content, expanded_before: expanded_before} + end + + @spec remove_child(t(), any()) :: ok_error() + def remove_child(%__MODULE__{children: children} = term, key) do + children + |> Enum.find_index(fn {child_key, _} -> child_key == key end) + |> case do + nil -> + {:error, :child_not_found} + + index -> + children = List.delete_at(children, index) + {:ok, %__MODULE__{term | children: children}} + end + end + + @spec update_child(t(), any(), (t() -> t())) :: ok_error() + def update_child(%__MODULE__{children: children} = term, key, update_fn) do + children + |> Enum.with_index() + |> Enum.filter(fn {{child_key, _}, _} -> child_key == key end) + |> case do + [{{^key, child}, index}] -> + children = List.replace_at(children, index, {key, update_fn.(child)}) + {:ok, %__MODULE__{term | children: children}} + + _ -> + {:error, :child_not_found} + end + end +end diff --git a/lib/live_debugger/app/utils/term_parser.ex b/lib/live_debugger/app/utils/term_parser.ex index c3c475932..5813d00bf 100644 --- a/lib/live_debugger/app/utils/term_parser.ex +++ b/lib/live_debugger/app/utils/term_parser.ex @@ -1,133 +1,18 @@ defmodule LiveDebugger.App.Utils.TermParser do @moduledoc """ - This module provides functions to parse terms into display tree. - Based on [Kino.Tree](https://github.com/livebook-dev/kino/blob/main/lib/kino/tree.ex) + This module provides functions to parse elixir terms. """ alias LiveDebugger.App.Utils.TermDiffer.Diff alias LiveDebugger.App.Utils.TermDiffer + alias LiveDebugger.App.Utils.TermNode.DisplayElement + alias LiveDebugger.App.Utils.TermNode - require Logger - - defmodule DisplayElement do - @moduledoc false - defstruct [:text, color: nil] - - @type t :: %__MODULE__{ - text: String.t(), - color: String.t() | nil - } - - @spec blue(String.t()) :: t() - def blue(text), do: %__MODULE__{text: text, color: "text-code-1"} - - @spec black(String.t()) :: t() - def black(text), do: %__MODULE__{text: text, color: "text-code-2"} - - @spec magenta(String.t()) :: t() - def magenta(text), do: %__MODULE__{text: text, color: "text-code-3"} - - @spec green(String.t()) :: t() - def green(text), do: %__MODULE__{text: text, color: "text-code-4"} - end - - defmodule TermNode do - @moduledoc """ - Represents a node in the display tree. - - - `id`: The id of the node. It uses dot notation to represent the path to the node. - - `open?`: Whether the node is expanded - - `kind`: The type of the node (e.g., :atom, :list, :map). - - `children`: A map of child nodes with keys (integer indices for lists/tuples, actual keys for maps). - - `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 [:id, :kind, :children, :content, :expanded_before, :expanded_after, open?: false] - - @type kind() :: :atom | :binary | :number | :tuple | :list | :map | :struct | :regex | :other - - @type t :: %__MODULE__{ - id: String.t(), - kind: kind(), - open?: boolean(), - children: [{any(), t()}], - content: [DisplayElement.t()], - expanded_before: [DisplayElement.t()] | nil, - expanded_after: [DisplayElement.t()] | nil - } - - @type ok_error() :: {:ok, t()} | {:error, any()} - - @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: children}), do: length(children) - - @spec add_suffix(t(), [DisplayElement.t()]) :: t() - def add_suffix( - %__MODULE__{content: content, expanded_after: expanded_after} = term_node, - suffix - ) - when is_list(suffix) do - content = content ++ suffix - expanded_after = expanded_after ++ suffix - %__MODULE__{term_node | content: content, expanded_after: expanded_after} - end - - @spec remove_suffix(t()) :: t() - def remove_suffix(%__MODULE__{content: content, expanded_after: expanded_after} = term_node) do - content = content |> Enum.reverse() |> tl() |> Enum.reverse() - expanded_after = expanded_after |> Enum.reverse() |> tl() |> Enum.reverse() - - %__MODULE__{term_node | content: content, expanded_after: expanded_after} - end - - @spec add_prefix(t(), [DisplayElement.t()]) :: t() - def add_prefix( - %__MODULE__{content: content, expanded_before: expanded_before} = term_node, - prefix - ) - when is_list(prefix) do - content = prefix ++ content - expanded_before = prefix ++ expanded_before - %__MODULE__{term_node | content: content, expanded_before: expanded_before} - end - - @spec remove_child(t(), any()) :: {:ok, t()} | {:error, any()} - def remove_child(%__MODULE__{children: children} = term, key) do - children - |> Enum.find_index(fn {child_key, _} -> child_key == key end) - |> case do - nil -> - {:error, :child_not_found} - - index -> - children = List.delete_at(children, index) - {:ok, %TermNode{term | children: children}} - end - end - - @spec update_child(t(), any(), (t() -> t())) :: {:ok, t()} | {:error, any()} - def update_child(%__MODULE__{children: children} = term, key, update_fn) do - children - |> Enum.with_index() - |> Enum.filter(fn {{child_key, _}, _} -> child_key == key end) - |> case do - [{{^key, child}, index}] -> - children = List.replace_at(children, index, {key, update_fn.(child)}) - {:ok, %TermNode{term | children: children}} - - _ -> - {:error, :child_not_found} - end - end - end - - @comma_suffix DisplayElement.black(",") + @list_and_tuple_open_limit 3 + @doc """ + Convert term into infinite string which can be copied to IEx console. + """ @spec term_to_copy_string(term()) :: String.t() def term_to_copy_string(term) do term @@ -140,21 +25,37 @@ defmodule LiveDebugger.App.Utils.TermParser do end @doc """ - It creates an indexed TermNode tree with comma suffixes and first element opened. + It creates a TermNode tree ready for display. + + It includes: + - Indexing the tree + - Adding comma suffixes + - Opening proper elements """ @spec term_to_display_tree(term()) :: TermNode.t() def term_to_display_tree(term) do - node = - term - |> to_node() - |> index_term_node() - |> update_comma_suffixes() - - %TermNode{node | open?: true} + term + |> to_node() + |> index_term_node() + |> update_comma_suffixes() + |> default_open_settings() end + @doc """ + Updates the term node by id. + + ## Examples + + iex> term_node = TermParser.term_to_display_tree(term) + iex> update_by_id(term_node, "root.0", fn term_node -> + ...> %{term_node | content: [DisplayElement.blue("new value")]} + ...> end) + {:ok, %TermNode{...}} + """ @spec update_by_id(TermNode.t(), String.t(), (TermNode.t() -> TermNode.ok_error())) :: TermNode.ok_error() + def update_by_id(term_node, path, update_fn) + def update_by_id(term_node, "root", update_fn) do update_fn.(term_node) end @@ -165,6 +66,16 @@ defmodule LiveDebugger.App.Utils.TermParser do update_by_path(term_node, path, update_fn) end + @doc """ + Updates the term node using calculated term #{Diff}. + + ## Examples + + iex> term_node = TermParser.term_to_display_tree(term) + iex> diff = TermDiffer.diff(term, new_term) + iex> update_by_diff(term_node, diff) + {:ok, %TermNode{...}} + """ @spec update_by_diff(TermNode.t(), Diff.t()) :: TermNode.ok_error() def update_by_diff(term_node, diff) do term_node = @@ -179,83 +90,101 @@ defmodule LiveDebugger.App.Utils.TermParser do {:error, "Invalid diff or term node: #{inspect(error)}"} end + @spec update_by_path(TermNode.t(), [integer()], (TermNode.t() -> TermNode.ok_error())) :: + TermNode.ok_error() + defp update_by_path(term, [index | path], update_fn) do + with {key, child} <- Enum.at(term.children, index), + {:ok, updated_child} <- update_by_path(child, path, update_fn) do + children = List.replace_at(term.children, index, {key, updated_child}) + {:ok, %TermNode{term | children: children}} + else + nil -> + {:error, :child_not_found} + + {:error, reason} -> + {:error, reason} + end + end + + defp update_by_path(term, [], update_fn) do + update_fn.(term) + end + @spec update_by_diff!(TermNode.t(), Diff.t(), Keyword.t()) :: TermNode.t() defp update_by_diff!(term_node, diff, opts \\ []) defp update_by_diff!(_, %Diff{type: :primitive} = diff, opts) do term = TermDiffer.primitive_new_value(diff) - maybe_to_key_value_node(term, opts) + case Keyword.get(opts, :key, nil) do + nil -> + to_node(term) + + key -> + {key, term} + |> to_key_value_node() + |> elem(1) + end end defp update_by_diff!(term_node, %Diff{type: type, ins: ins, del: del}, _opts) when type in [:list, :tuple] do - term_node = - Enum.reduce(del, term_node, fn {index, _}, term_node -> - {:ok, term_node} = TermNode.remove_child(term_node, index) - term_node - end) - - term_node = - Enum.reduce(ins, term_node, fn {index, term}, term_node -> - children = List.insert_at(term_node.children, index, {index, to_node(term)}) - %TermNode{term_node | children: children} - end) - term_node + |> term_node_reduce_del(del) + |> term_node_reduce_list_ins(ins) end defp update_by_diff!(term_node, %Diff{type: :struct, diff: diff}, _opts) do - Enum.reduce(diff, term_node, fn {key, child_diff}, term_node -> - {:ok, term_node} = - TermNode.update_child(term_node, key, fn child -> - update_by_diff!(child, child_diff, key: key) - end) + term_node_reduce_diff!(term_node, diff) + end - term_node + defp update_by_diff!(term_node, %Diff{type: :map, ins: ins, del: del, diff: diff}, _opts) do + term_node + |> term_node_reduce_del(del) + |> term_node_reduce_map_ins(ins) + |> term_node_reduce_diff!(diff) + end + + defp term_node_reduce_del(term_node, del) do + Enum.reduce(del, term_node, fn {key, _}, term_node_acc -> + {:ok, term_node_acc} = TermNode.remove_child(term_node_acc, key) + term_node_acc end) end - defp update_by_diff!(term_node, %Diff{type: :map, ins: ins, del: del, diff: diff}, _opts) do - term_node = - Enum.reduce(del, term_node, fn {key, _}, term_node -> - {:ok, term_node} = TermNode.remove_child(term_node, key) - term_node - end) + defp term_node_reduce_list_ins(term_node, ins) do + Enum.reduce(ins, term_node, fn {index, term}, term_node_acc -> + children = List.insert_at(term_node_acc.children, index, {index, to_node(term)}) + %TermNode{term_node_acc | children: children} + end) + end + defp term_node_reduce_map_ins(term_node, ins) do child_keys = term_node.children |> Enum.map(fn {key, _} -> key end) - {term_node, _} = - Enum.reduce(ins, {term_node, child_keys}, fn {key, term}, {term_node, child_keys} -> - child_term_node = maybe_to_key_value_node(term, key: key) + {term_node_acc, _} = + Enum.reduce(ins, {term_node, child_keys}, fn {key, term}, {term_node_acc, child_keys} -> child_keys = Enum.sort(child_keys ++ [key]) index = Enum.find_index(child_keys, &(&1 == key)) - children = List.insert_at(term_node.children, index, child_term_node) - {%TermNode{term_node | children: children}, child_keys} + + children = List.insert_at(term_node_acc.children, index, to_key_value_node({key, term})) + {%TermNode{term_node_acc | children: children}, child_keys} end) - Enum.reduce(diff, term_node, fn {key, child_diff}, term_node -> - {:ok, term_node} = - TermNode.update_child(term_node, key, fn child -> + term_node_acc + end + + defp term_node_reduce_diff!(term_node, diff) do + Enum.reduce(diff, term_node, fn {key, child_diff}, term_node_acc -> + {:ok, term_node_acc} = + TermNode.update_child(term_node_acc, key, fn child -> update_by_diff!(child, child_diff, key: key) end) - term_node + term_node_acc end) end - defp maybe_to_key_value_node(term, opts) do - case Keyword.get(opts, :key, nil) do - nil -> - to_node(term) - - key -> - {key, term} - |> to_key_value_node() - |> elem(1) - end - end - @spec index_term_node(TermNode.t()) :: TermNode.t() defp index_term_node(%TermNode{children: children} = term_node, id_path \\ "root") do new_children = @@ -284,10 +213,10 @@ defmodule LiveDebugger.App.Utils.TermParser do child = case {last_child?, has_comma_suffix?(child)} do {true, true} -> - child |> TermNode.remove_suffix() + TermNode.remove_suffix(child) {false, false} -> - child |> TermNode.add_suffix([@comma_suffix]) + TermNode.add_suffix(child, [TermNode.comma_suffix()]) _ -> child @@ -299,29 +228,9 @@ defmodule LiveDebugger.App.Utils.TermParser do %TermNode{term_node | children: children} end - @spec update_by_path(TermNode.t(), [integer()], (TermNode.t() -> TermNode.ok_error())) :: - TermNode.ok_error() - defp update_by_path(term, [index | path], update_fn) do - with {key, child} <- Enum.at(term.children, index), - {:ok, updated_child} <- update_by_path(child, path, update_fn) do - children = List.replace_at(term.children, index, {key, updated_child}) - {:ok, %TermNode{term | children: children}} - else - nil -> - {:error, :child_not_found} - - {:error, reason} -> - {:error, reason} - end - end - - defp update_by_path(term, [], update_fn) do - update_fn.(term) - end - @spec to_node(term()) :: TermNode.t() defp to_node(string) when is_binary(string) do - node(:binary, [DisplayElement.green(inspect(string))]) + TermNode.new(:binary, [DisplayElement.green(inspect(string))]) end defp to_node(atom) when is_atom(atom) do @@ -332,21 +241,21 @@ defmodule LiveDebugger.App.Utils.TermParser do DisplayElement.blue(inspect(atom)) end - node(:atom, [span]) + TermNode.new(:atom, [span]) end defp to_node(number) when is_number(number) do - node(:number, [DisplayElement.blue(inspect(number))]) + TermNode.new(:number, [DisplayElement.blue(inspect(number))]) end defp to_node({}) do - node(:tuple, [DisplayElement.black("{}")]) + TermNode.new(:tuple, [DisplayElement.black("{}")]) end defp to_node(tuple) when is_tuple(tuple) do children = tuple |> Tuple.to_list() |> to_children() - node(:tuple, [DisplayElement.black("{...}")], + TermNode.new(:tuple, [DisplayElement.black("{...}")], children: children, expanded_before: [DisplayElement.black("{")], expanded_after: [DisplayElement.black("}")] @@ -354,7 +263,7 @@ defmodule LiveDebugger.App.Utils.TermParser do end defp to_node([]) do - node(:list, [DisplayElement.black("[]")]) + TermNode.new(:list, [DisplayElement.black("[]")]) end defp to_node(list) when is_list(list) do @@ -365,7 +274,7 @@ defmodule LiveDebugger.App.Utils.TermParser do to_children(list) end - node(:list, [DisplayElement.black("[...]")], + TermNode.new(:list, [DisplayElement.black("[...]")], children: children, expanded_before: [DisplayElement.black("[")], expanded_after: [DisplayElement.black("]")] @@ -373,7 +282,7 @@ defmodule LiveDebugger.App.Utils.TermParser do end defp to_node(%Regex{} = regex) do - node(:regex, [DisplayElement.black(inspect(regex))]) + TermNode.new(:regex, [DisplayElement.black(inspect(regex))]) end defp to_node(%module{} = struct) when is_struct(struct) do @@ -394,7 +303,7 @@ defmodule LiveDebugger.App.Utils.TermParser do |> Map.to_list() |> to_key_value_children() - node( + TermNode.new( :struct, content, children: children, @@ -408,13 +317,13 @@ defmodule LiveDebugger.App.Utils.TermParser do end defp to_node(%{} = map) when map_size(map) == 0 do - node(:map, [DisplayElement.black("%{}")]) + TermNode.new(:map, [DisplayElement.black("%{}")]) end defp to_node(map) when is_map(map) do children = map |> Enum.sort() |> to_key_value_children() - node(:map, [DisplayElement.black("%{...}")], + TermNode.new(:map, [DisplayElement.black("%{...}")], children: children, expanded_before: [DisplayElement.black("%{")], expanded_after: [DisplayElement.black("}")] @@ -422,7 +331,7 @@ defmodule LiveDebugger.App.Utils.TermParser do end defp to_node(other) do - node(:other, [DisplayElement.black(inspect(other))]) + TermNode.new(:other, [DisplayElement.black(inspect(other))]) end defp to_key_value_node({key, value}) do @@ -454,27 +363,42 @@ defmodule LiveDebugger.App.Utils.TermParser do Enum.map(items, &to_key_value_node/1) end - defp node(kind, content, opts \\ []) when is_atom(kind) do - children = Keyword.get(opts, :children, []) - expanded_before = Keyword.get(opts, :expanded_before, []) - expanded_after = Keyword.get(opts, :expanded_after, []) - open? = Keyword.get(opts, :open?, false) - id = Keyword.get(opts, :id, "not_indexed") + defp default_open_settings(term_node) do + term_node + |> open_first_element() + |> open_small_lists_and_tuples() + end - %TermNode{ - id: id, - kind: kind, - content: content, - children: children, - expanded_before: expanded_before, - expanded_after: expanded_after, - open?: open? - } + defp open_first_element(term_node) when is_struct(term_node, TermNode) do + %TermNode{term_node | open?: true} + end + + defp open_small_lists_and_tuples(%TermNode{kind: kind, children: children} = term_node) + when kind in [:list, :tuple] do + children = + Enum.map(children, fn {key, child} -> + {key, open_small_lists_and_tuples(child)} + end) + + if length(children) < @list_and_tuple_open_limit do + %TermNode{term_node | open?: true, children: children} + else + %TermNode{term_node | children: children} + end + end + + defp open_small_lists_and_tuples(%TermNode{children: children} = term_node) do + children = + Enum.map(children, fn {key, child} -> + {key, open_small_lists_and_tuples(child)} + end) + + %TermNode{term_node | children: children} end defp has_comma_suffix?(%TermNode{content: content, expanded_after: expanded_after}) do - content? = last_item_equal?(content, @comma_suffix) - expanded_after? = last_item_equal?(expanded_after, @comma_suffix) + content? = last_item_equal?(content, TermNode.comma_suffix()) + expanded_after? = last_item_equal?(expanded_after, TermNode.comma_suffix()) if content? != expanded_after?, do: raise("Content and expanded_after must have the same comma suffix") From d47cd0070e8f191ec08139545590a6eac8ab1051 Mon Sep 17 00:00:00 2001 From: Alan Guzek Date: Wed, 15 Oct 2025 15:38:55 +0200 Subject: [PATCH 06/15] fix tests --- test/app/debugger/node_state/queries_test.exs | 8 +- test/app/utils/term_parser_test.exs | 209 +++++++++++------- 2 files changed, 133 insertions(+), 84 deletions(-) diff --git a/test/app/debugger/node_state/queries_test.exs b/test/app/debugger/node_state/queries_test.exs index 2dc7fe2c2..f09703fbb 100644 --- a/test/app/debugger/node_state/queries_test.exs +++ b/test/app/debugger/node_state/queries_test.exs @@ -18,7 +18,7 @@ defmodule LiveDebugger.App.Debugger.NodeState.QueriesTest do MockAPIStatesStorage |> expect(:get!, fn ^pid -> %LvState{socket: %{assigns: assigns}} end) - assert {:ok, %{node_assigns: ^assigns}} = NodeStateQueries.fetch_node_assigns(pid, pid) + assert {:ok, ^assigns} = NodeStateQueries.fetch_node_assigns(pid, pid) end test "returns assigns for a valid LiveView node when not saved in storage" do @@ -32,7 +32,7 @@ defmodule LiveDebugger.App.Debugger.NodeState.QueriesTest do |> expect(:socket, fn ^pid -> {:ok, %{assigns: assigns}} end) |> expect(:live_components, fn ^pid -> {:ok, []} end) - assert {:ok, %{node_assigns: ^assigns}} = NodeStateQueries.fetch_node_assigns(pid, pid) + assert {:ok, ^assigns} = NodeStateQueries.fetch_node_assigns(pid, pid) end test "returns assigns for a valid component CID when saved in storage" do @@ -43,7 +43,7 @@ defmodule LiveDebugger.App.Debugger.NodeState.QueriesTest do MockAPIStatesStorage |> expect(:get!, fn ^pid -> %LvState{components: [%{cid: cid.cid, assigns: assigns}]} end) - assert {:ok, %{node_assigns: ^assigns}} = NodeStateQueries.fetch_node_assigns(pid, cid) + assert {:ok, ^assigns} = NodeStateQueries.fetch_node_assigns(pid, cid) end test "returns assigns for a valid component CID when not saved in storage" do @@ -58,7 +58,7 @@ defmodule LiveDebugger.App.Debugger.NodeState.QueriesTest do |> expect(:socket, fn ^pid -> {:ok, %{}} end) |> expect(:live_components, fn ^pid -> {:ok, [%{cid: cid.cid, assigns: assigns}]} end) - assert {:ok, %{node_assigns: ^assigns}} = NodeStateQueries.fetch_node_assigns(pid, cid) + assert {:ok, ^assigns} = NodeStateQueries.fetch_node_assigns(pid, cid) end end end diff --git a/test/app/utils/term_parser_test.exs b/test/app/utils/term_parser_test.exs index fdba92d5f..2ae76f815 100644 --- a/test/app/utils/term_parser_test.exs +++ b/test/app/utils/term_parser_test.exs @@ -1,8 +1,8 @@ defmodule LiveDebugger.App.Utils.TermParserTest do use ExUnit.Case, async: true - alias LiveDebugger.App.Utils.TermParser.DisplayElement - alias LiveDebugger.App.Utils.TermParser.TermNode + alias LiveDebugger.App.Utils.TermNode + alias LiveDebugger.App.Utils.TermNode.DisplayElement alias LiveDebugger.App.Utils.TermParser defmodule TestStruct do @@ -19,8 +19,8 @@ defmodule LiveDebugger.App.Utils.TermParserTest do open?: true, children: [], content: [%DisplayElement{text: "\"Hello, World!\"", color: "text-code-4"}], - expanded_before: nil, - expanded_after: nil + expanded_before: [], + expanded_after: [] } assert TermParser.term_to_display_tree(term) == expected @@ -35,8 +35,8 @@ defmodule LiveDebugger.App.Utils.TermParserTest do open?: true, children: [], content: [%DisplayElement{text: ":hello", color: "text-code-1"}], - expanded_before: nil, - expanded_after: nil + expanded_before: [], + expanded_after: [] } assert TermParser.term_to_display_tree(term) == expected @@ -51,8 +51,8 @@ defmodule LiveDebugger.App.Utils.TermParserTest do open?: true, children: [], content: [%DisplayElement{text: "42", color: "text-code-1"}], - expanded_before: nil, - expanded_after: nil + expanded_before: [], + expanded_after: [] } assert TermParser.term_to_display_tree(term) == expected @@ -64,28 +64,29 @@ defmodule LiveDebugger.App.Utils.TermParserTest do expected = %TermNode{ id: "root", kind: :tuple, + open?: true, children: [ - %TermNode{ + {0, %TermNode{ id: "root.0", kind: :atom, - open?: true, + open?: false, children: [], content: [ %DisplayElement{text: ":ok", color: "text-code-1"}, %DisplayElement{text: ",", color: "text-code-2"} ], - expanded_before: nil, - expanded_after: nil - }, - %TermNode{ + expanded_before: [], + expanded_after: [%DisplayElement{text: ",", color: "text-code-2"}] + }}, + {1, %TermNode{ id: "root.1", kind: :binary, - open?: true, + open?: false, children: [], content: [%DisplayElement{text: "\"Hello\"", color: "text-code-4"}], - expanded_before: nil, - expanded_after: nil - } + expanded_before: [], + expanded_after: [] + }} ], content: [%DisplayElement{text: "{...}", color: "text-code-2"}], expanded_before: [%DisplayElement{text: "{", color: "text-code-2"}], @@ -104,8 +105,8 @@ defmodule LiveDebugger.App.Utils.TermParserTest do open?: true, children: [], content: [%DisplayElement{text: "{}", color: "text-code-2"}], - expanded_before: nil, - expanded_after: nil + expanded_before: [], + expanded_after: [] } assert TermParser.term_to_display_tree(term) == expected @@ -119,36 +120,39 @@ defmodule LiveDebugger.App.Utils.TermParserTest do kind: :list, open?: true, children: [ - %TermNode{ + {0, %TermNode{ id: "root.0", kind: :number, + open?: false, children: [], content: [ %DisplayElement{text: "1", color: "text-code-1"}, %DisplayElement{text: ",", color: "text-code-2"} ], - expanded_before: nil, - expanded_after: nil - }, - %TermNode{ + expanded_before: [], + expanded_after: [%DisplayElement{text: ",", color: "text-code-2"}] + }}, + {1, %TermNode{ id: "root.1", kind: :number, + open?: false, children: [], content: [ %DisplayElement{text: "2", color: "text-code-1"}, %DisplayElement{text: ",", color: "text-code-2"} ], - expanded_before: nil, - expanded_after: nil - }, - %TermNode{ + expanded_before: [], + expanded_after: [%DisplayElement{text: ",", color: "text-code-2"}] + }}, + {2, %TermNode{ id: "root.2", kind: :number, + open?: false, children: [], content: [%DisplayElement{text: "3", color: "text-code-1"}], - expanded_before: nil, - expanded_after: nil - } + expanded_before: [], + expanded_after: [] + }} ], content: [%DisplayElement{text: "[...]", color: "text-code-2"}], expanded_before: [%DisplayElement{text: "[", color: "text-code-2"}], @@ -167,8 +171,8 @@ defmodule LiveDebugger.App.Utils.TermParserTest do open?: true, children: [], content: [%DisplayElement{text: "[]", color: "text-code-2"}], - expanded_before: nil, - expanded_after: nil + expanded_before: [], + expanded_after: [] } assert TermParser.term_to_display_tree(term) == expected @@ -183,8 +187,8 @@ defmodule LiveDebugger.App.Utils.TermParserTest do open?: true, children: [], content: [%DisplayElement{text: "~r/hello/", color: "text-code-2"}], - expanded_before: nil, - expanded_after: nil + expanded_before: [], + expanded_after: [] } assert TermParser.term_to_display_tree(term) == expected @@ -200,8 +204,8 @@ defmodule LiveDebugger.App.Utils.TermParserTest do open?: true, children: [], content: [%DisplayElement{text: inspect(term), color: "text-code-3"}], - expanded_before: nil, - expanded_after: nil + expanded_before: [], + expanded_after: [] } assert TermParser.term_to_display_tree(term) == expected @@ -217,8 +221,8 @@ defmodule LiveDebugger.App.Utils.TermParserTest do open?: true, children: [], content: [%DisplayElement{text: "%{}", color: "text-code-2"}], - expanded_before: nil, - expanded_after: nil + expanded_before: [], + expanded_after: [] } assert TermParser.term_to_display_tree(term) == expected @@ -234,9 +238,10 @@ defmodule LiveDebugger.App.Utils.TermParserTest do kind: :struct, open?: true, children: [ - %TermNode{ + field1: %TermNode{ id: "root.0", kind: :binary, + open?: false, children: [], content: [ %DisplayElement{text: "field1:", color: "text-code-1"}, @@ -244,20 +249,27 @@ defmodule LiveDebugger.App.Utils.TermParserTest do %DisplayElement{text: "\"value1\"", color: "text-code-4"}, %DisplayElement{text: ",", color: "text-code-2"} ], - expanded_before: nil, - expanded_after: nil + expanded_before: [ + %DisplayElement{text: "field1:", color: "text-code-1"}, + %DisplayElement{text: " ", color: "text-code-2"} + ], + expanded_after: [%DisplayElement{text: ",", color: "text-code-2"}] }, - %TermNode{ + field2: %TermNode{ id: "root.1", kind: :number, + open?: false, children: [], content: [ %DisplayElement{text: "field2:", color: "text-code-1"}, %DisplayElement{text: " ", color: "text-code-2"}, %DisplayElement{text: "42", color: "text-code-1"} ], - expanded_before: nil, - expanded_after: nil + expanded_before: [ + %DisplayElement{text: "field2:", color: "text-code-1"}, + %DisplayElement{text: " ", color: "text-code-2"} + ], + expanded_after: [] } ], content: [ @@ -284,9 +296,10 @@ defmodule LiveDebugger.App.Utils.TermParserTest do kind: :struct, open?: true, children: [ - %TermNode{ + calendar: %TermNode{ id: "root.0", kind: :atom, + open?: false, children: [], content: [ %DisplayElement{text: "calendar:", color: "text-code-1"}, @@ -294,12 +307,16 @@ defmodule LiveDebugger.App.Utils.TermParserTest do %DisplayElement{text: "Calendar.ISO", color: "text-code-1"}, %DisplayElement{text: ",", color: "text-code-2"} ], - expanded_before: nil, - expanded_after: nil + expanded_before: [ + %DisplayElement{text: "calendar:", color: "text-code-1"}, + %DisplayElement{text: " ", color: "text-code-2"} + ], + expanded_after: [%DisplayElement{text: ",", color: "text-code-2"}] }, - %TermNode{ + month: %TermNode{ id: "root.1", kind: :number, + open?: false, children: [], content: [ %DisplayElement{text: "month:", color: "text-code-1"}, @@ -307,12 +324,16 @@ defmodule LiveDebugger.App.Utils.TermParserTest do %DisplayElement{text: "5", color: "text-code-1"}, %DisplayElement{text: ",", color: "text-code-2"} ], - expanded_before: nil, - expanded_after: nil + expanded_before: [ + %DisplayElement{text: "month:", color: "text-code-1"}, + %DisplayElement{text: " ", color: "text-code-2"} + ], + expanded_after: [%DisplayElement{text: ",", color: "text-code-2"}] }, - %TermNode{ + day: %TermNode{ id: "root.2", kind: :number, + open?: false, children: [], content: [ %DisplayElement{text: "day:", color: "text-code-1"}, @@ -320,20 +341,27 @@ defmodule LiveDebugger.App.Utils.TermParserTest do %DisplayElement{text: "10", color: "text-code-1"}, %DisplayElement{text: ",", color: "text-code-2"} ], - expanded_before: nil, - expanded_after: nil + expanded_before: [ + %DisplayElement{text: "day:", color: "text-code-1"}, + %DisplayElement{text: " ", color: "text-code-2"} + ], + expanded_after: [%DisplayElement{text: ",", color: "text-code-2"}] }, - %TermNode{ + year: %TermNode{ id: "root.3", kind: :number, + open?: false, children: [], content: [ %DisplayElement{text: "year:", color: "text-code-1"}, %DisplayElement{text: " ", color: "text-code-2"}, %DisplayElement{text: "2023", color: "text-code-1"} ], - expanded_before: nil, - expanded_after: nil + expanded_before: [ + %DisplayElement{text: "year:", color: "text-code-1"}, + %DisplayElement{text: " ", color: "text-code-2"} + ], + expanded_after: [] } ], content: [%DisplayElement{text: "~D[2023-05-10]", color: "text-code-2"}], @@ -342,8 +370,7 @@ defmodule LiveDebugger.App.Utils.TermParserTest do %DisplayElement{text: "Date", color: "text-code-1"}, %DisplayElement{text: "{", color: "text-code-2"} ], - expanded_after: [%DisplayElement{text: "}", color: "text-code-2"}], - kind: :struct + expanded_after: [%DisplayElement{text: "}", color: "text-code-2"}] } assert TermParser.term_to_display_tree(term) == expected @@ -361,9 +388,10 @@ defmodule LiveDebugger.App.Utils.TermParserTest do kind: :map, open?: true, children: [ - %TermNode{ + {"key1", %TermNode{ id: "root.0", kind: :binary, + open?: false, children: [], content: [ %DisplayElement{text: "\"key1\"", color: "text-code-4"}, @@ -371,12 +399,16 @@ defmodule LiveDebugger.App.Utils.TermParserTest do %DisplayElement{text: "\"value1\"", color: "text-code-4"}, %DisplayElement{text: ",", color: "text-code-2"} ], - expanded_before: nil, - expanded_after: nil - }, - %TermNode{ + expanded_before: [ + %DisplayElement{text: "\"key1\"", color: "text-code-4"}, + %DisplayElement{text: " => ", color: "text-code-2"} + ], + expanded_after: [%DisplayElement{text: ",", color: "text-code-2"}] + }}, + {"key2", %TermNode{ id: "root.1", kind: :number, + open?: false, children: [], content: [ %DisplayElement{text: "\"key2\"", color: "text-code-4"}, @@ -384,21 +416,28 @@ defmodule LiveDebugger.App.Utils.TermParserTest do %DisplayElement{text: "42", color: "text-code-1"}, %DisplayElement{text: ",", color: "text-code-2"} ], - expanded_before: nil, - expanded_after: nil - }, - %TermNode{ + expanded_before: [ + %DisplayElement{text: "\"key2\"", color: "text-code-4"}, + %DisplayElement{text: " => ", color: "text-code-2"} + ], + expanded_after: [%DisplayElement{text: ",", color: "text-code-2"}] + }}, + {"key3", %TermNode{ id: "root.2", kind: :atom, + open?: false, children: [], content: [ %DisplayElement{text: "\"key3\"", color: "text-code-4"}, %DisplayElement{text: " => ", color: "text-code-2"}, %DisplayElement{text: ":test", color: "text-code-1"} ], - expanded_before: nil, - expanded_after: nil - } + expanded_before: [ + %DisplayElement{text: "\"key3\"", color: "text-code-4"}, + %DisplayElement{text: " => ", color: "text-code-2"} + ], + expanded_after: [] + }} ], content: [%DisplayElement{text: "%{...}", color: "text-code-2"}], expanded_before: [%DisplayElement{text: "%{", color: "text-code-2"}], @@ -414,14 +453,17 @@ defmodule LiveDebugger.App.Utils.TermParserTest do Date.new(2025, 7, 8) => "Date" } + {{:ok, date}, cid} = {Date.new(2025, 7, 8), %Phoenix.LiveComponent.CID{cid: 1}} + expected = %TermNode{ id: "root", kind: :map, open?: true, children: [ - %TermNode{ + {{:ok, date}, %TermNode{ id: "root.0", kind: :binary, + open?: false, children: [], content: [ %DisplayElement{text: "{:ok, ~D[2025-07-08]}", color: "text-code-2"}, @@ -429,21 +471,28 @@ defmodule LiveDebugger.App.Utils.TermParserTest do %DisplayElement{text: "\"Date\"", color: "text-code-4"}, %DisplayElement{text: ",", color: "text-code-2"} ], - expanded_before: nil, - expanded_after: nil - }, - %TermNode{ + expanded_before: [ + %DisplayElement{text: "{:ok, ~D[2025-07-08]}", color: "text-code-2"}, + %DisplayElement{text: " => ", color: "text-code-2"} + ], + expanded_after: [%DisplayElement{text: ",", color: "text-code-2"}] + }}, + {cid, %TermNode{ id: "root.1", kind: :binary, + open?: false, children: [], content: [ %DisplayElement{text: "%Phoenix.LiveComponent.CID{cid: 1}", color: "text-code-1"}, %DisplayElement{text: " => ", color: "text-code-2"}, %DisplayElement{text: "\"CID\"", color: "text-code-4"} ], - expanded_before: nil, - expanded_after: nil - } + expanded_before: [ + %DisplayElement{text: "%Phoenix.LiveComponent.CID{cid: 1}", color: "text-code-1"}, + %DisplayElement{text: " => ", color: "text-code-2"} + ], + expanded_after: [] + }} ], content: [%DisplayElement{text: "%{...}", color: "text-code-2"}], expanded_before: [%DisplayElement{text: "%{", color: "text-code-2"}], From 4e004b5d5f47ff374dadcf7a8a52a4e6328f7ba2 Mon Sep 17 00:00:00 2001 From: Alan Guzek Date: Wed, 15 Oct 2025 16:08:25 +0200 Subject: [PATCH 07/15] use open? in elixir display --- .../callback_tracing/web/components/trace.ex | 6 +----- .../app/debugger/web/components/elixir_display.ex | 14 ++------------ 2 files changed, 3 insertions(+), 17 deletions(-) diff --git a/lib/live_debugger/app/debugger/callback_tracing/web/components/trace.ex b/lib/live_debugger/app/debugger/callback_tracing/web/components/trace.ex index 1705d7d62..4ed4fbd4d 100644 --- a/lib/live_debugger/app/debugger/callback_tracing/web/components/trace.ex +++ b/lib/live_debugger/app/debugger/callback_tracing/web/components/trace.ex @@ -50,11 +50,7 @@ defmodule LiveDebugger.App.Debugger.CallbackTracing.Web.Components.Trace do

<.copy_button id={"#{@id}-arg-#{index}"} value={TermParser.term_to_copy_string(args)} />
- "-#{index}"} - node={TermParser.term_to_display_tree(args)} - level={1} - /> + "-#{index}"} node={TermParser.term_to_display_tree(args)} />
<% end %> 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 4386ce15a..03947df7e 100644 --- a/lib/live_debugger/app/debugger/web/components/elixir_display.ex +++ b/lib/live_debugger/app/debugger/web/components/elixir_display.ex @@ -9,21 +9,17 @@ defmodule LiveDebugger.App.Debugger.Web.Components.ElixirDisplay do alias LiveDebugger.App.Utils.TermNode.DisplayElement alias LiveDebugger.App.Utils.TermNode - @max_auto_expand_size 6 - @doc """ Returns a tree of terms. """ attr(:id, :string, required: true) attr(:node, TermNode, required: true) - attr(:level, :integer, default: 1) def term(assigns) do assigns = assigns |> assign(:id, "#{assigns.id}-#{assigns.node.id}") - |> assign(:auto_open?, auto_open?(assigns.node, assigns.level)) |> assign(:has_children?, TermNode.has_children?(assigns.node)) ~H""" @@ -34,7 +30,7 @@ defmodule LiveDebugger.App.Debugger.Web.Components.ElixirDisplay do <.collapsible :if={@has_children?} id={@id <> "collapsible"} - open={@auto_open?} + open={@node.open?} icon="icon-chevron-right" label_class="max-w-max" chevron_class="text-code-2 m-auto w-[2ch] h-[2ch]" @@ -52,7 +48,7 @@ defmodule LiveDebugger.App.Debugger.Web.Components.ElixirDisplay do
  1. - <.term id={@id} node={child} level={@level + 1} /> + <.term id={@id} node={child} />
@@ -80,10 +76,4 @@ defmodule LiveDebugger.App.Debugger.Web.Components.ElixirDisplay do defp text_item_color_class(%DisplayElement{color: color}) do if color, do: "#{color}", else: "" end - - defp auto_open?(%TermNode{}, 1), do: true - - defp auto_open?(%TermNode{} = node, _level) do - node.kind == :tuple and TermNode.children_number(node) <= @max_auto_expand_size - end end From acd8f3a39b3ab3c66db2aa258b1ac5a728916703 Mon Sep 17 00:00:00 2001 From: Alan Guzek Date: Wed, 15 Oct 2025 16:12:45 +0200 Subject: [PATCH 08/15] add term_node tests --- lib/live_debugger/app/utils/term_node.ex | 10 +- lib/live_debugger/app/utils/term_parser.ex | 2 +- test/app/utils/term_node_test.exs | 173 +++++++++++++++++++++ test/support/fakes.ex | 19 +++ 4 files changed, 199 insertions(+), 5 deletions(-) create mode 100644 test/app/utils/term_node_test.exs diff --git a/lib/live_debugger/app/utils/term_node.ex b/lib/live_debugger/app/utils/term_node.ex index 916182f57..b4cb37fd6 100644 --- a/lib/live_debugger/app/utils/term_node.ex +++ b/lib/live_debugger/app/utils/term_node.ex @@ -90,14 +90,16 @@ defmodule LiveDebugger.App.Utils.TermNode do %__MODULE__{term_node | content: content, expanded_after: expanded_after} end - @spec remove_suffix(t()) :: t() - def remove_suffix(%__MODULE__{content: content, expanded_after: expanded_after} = term_node) do - content = content |> Enum.reverse() |> tl() |> Enum.reverse() - expanded_after = expanded_after |> Enum.reverse() |> tl() |> Enum.reverse() + @spec remove_suffix!(t()) :: t() + def remove_suffix!(%__MODULE__{content: [_ | _], expanded_after: [_ | _]} = term_node) do + content = term_node.content |> Enum.reverse() |> tl() |> Enum.reverse() + expanded_after = term_node.expanded_after |> Enum.reverse() |> tl() |> Enum.reverse() %__MODULE__{term_node | content: content, expanded_after: expanded_after} end + def remove_suffix!(%__MODULE__{}), do: raise("Term node has no suffix to remove") + @spec add_prefix(t(), [DisplayElement.t()]) :: t() def add_prefix( %__MODULE__{content: content, expanded_before: expanded_before} = term_node, diff --git a/lib/live_debugger/app/utils/term_parser.ex b/lib/live_debugger/app/utils/term_parser.ex index 5813d00bf..985fd2d86 100644 --- a/lib/live_debugger/app/utils/term_parser.ex +++ b/lib/live_debugger/app/utils/term_parser.ex @@ -213,7 +213,7 @@ defmodule LiveDebugger.App.Utils.TermParser do child = case {last_child?, has_comma_suffix?(child)} do {true, true} -> - TermNode.remove_suffix(child) + TermNode.remove_suffix!(child) {false, false} -> TermNode.add_suffix(child, [TermNode.comma_suffix()]) diff --git a/test/app/utils/term_node_test.exs b/test/app/utils/term_node_test.exs new file mode 100644 index 000000000..e9f9a5f65 --- /dev/null +++ b/test/app/utils/term_node_test.exs @@ -0,0 +1,173 @@ +defmodule LiveDebugger.App.Utils.TermNodeTest do + use ExUnit.Case, async: true + + alias LiveDebugger.App.Utils.TermNode + alias LiveDebugger.App.Utils.TermNode.DisplayElement + alias LiveDebugger.Fakes + + describe "new/3" do + test "creates a new term node with default values" do + content = [%DisplayElement{text: "atom", color: "text-code-1"}] + kind = :atom + + assert %TermNode{ + id: "not_indexed", + kind: :atom, + content: ^content, + children: [], + expanded_before: [], + expanded_after: [], + open?: false + } = TermNode.new(kind, content) + end + + test "creates a new term node with custom values" do + id = "custom_id" + content = [Fakes.display_element()] + kind = :atom + + children = [ + Fakes.term_node(id: "not_indexed", content: [Fakes.display_element(text: "child")]) + ] + + expanded_before = [Fakes.display_element(text: "expanded_before")] + expanded_after = [Fakes.display_element(text: "expanded_after")] + open? = true + + assert %TermNode{ + id: ^id, + kind: ^kind, + content: ^content, + children: ^children, + expanded_before: ^expanded_before, + expanded_after: ^expanded_after, + open?: ^open? + } = + TermNode.new(kind, content, + id: id, + children: children, + expanded_before: expanded_before, + expanded_after: expanded_after, + open?: open? + ) + end + end + + describe "has_children?/1" do + test "returns true if the term node has children" do + assert TermNode.has_children?(%TermNode{children: [1, 2, 3]}) + end + + test "returns false if the term node has no children" do + assert not TermNode.has_children?(%TermNode{children: []}) + end + end + + describe "children_number/1" do + test "returns the number of children" do + assert TermNode.children_number(%TermNode{children: [1, 2, 3]}) == 3 + end + + test "returns 0 if the term node has no children" do + assert TermNode.children_number(%TermNode{children: []}) == 0 + end + end + + describe "add_suffix/2 properly adds suffix to the term node's content and expanded_after" do + content = Fakes.display_element(text: "content") + + term_node = Fakes.term_node(id: "root", content: [content]) + + suffix = Fakes.display_element(text: "suffix") + + assert %TermNode{ + content: [^content, ^suffix], + children: [], + expanded_before: [], + expanded_after: [^suffix] + } = TermNode.add_suffix(term_node, [suffix]) + end + + describe "remove_suffix!/1" do + test "removes last element from the term node's content and expanded_after" do + suffix = Fakes.display_element(text: "suffix") + + term_node = Fakes.term_node(id: "root", content: [suffix], expanded_after: [suffix]) + + assert %TermNode{ + id: "root", + kind: :atom, + content: [], + children: [], + expanded_before: [], + expanded_after: [] + } = TermNode.remove_suffix!(term_node) + end + + test "raises an error if the term node has no suffix to remove" do + assert_raise RuntimeError, "Term node has no suffix to remove", fn -> + TermNode.remove_suffix!(%TermNode{content: [], expanded_after: []}) + end + end + end + + test "add_prefix/2 properly adds prefix to the term node's content and expanded_before" do + content = Fakes.display_element(text: "content") + + term_node = Fakes.term_node(id: "root", content: [content]) + + prefix = Fakes.display_element(text: "prefix") + + assert %TermNode{ + content: [^prefix, ^content], + children: [], + expanded_before: [^prefix], + expanded_after: [] + } = TermNode.add_prefix(term_node, [prefix]) + end + + describe "remove_child/2" do + test "removes the child from the term node and returns ok tuple" do + child2 = Fakes.term_node(id: "root.1") + child1 = Fakes.term_node(id: "root.0") + + term_node = Fakes.term_node(kind: :tuple, id: "root", children: [{0, child1}, {1, child2}]) + + assert {:ok, + %TermNode{ + id: "root", + kind: :tuple, + children: [{0, ^child1}] + }} = TermNode.remove_child(term_node, 1) + end + + test "returns error if the child is not found" do + child1 = Fakes.term_node(id: "root.0") + term_node = Fakes.term_node(kind: :tuple, id: "root", children: [{0, child1}]) + + assert {:error, :child_not_found} = TermNode.remove_child(term_node, 1) + end + end + + describe "update_child/3" do + test "updates the child and returns ok tuple" do + child = Fakes.term_node(id: "root.0") + term_node = Fakes.term_node(kind: :tuple, id: "root", children: [{0, child}]) + + updated_content = [Fakes.display_element(text: "updated")] + + update_function = fn child -> %TermNode{child | content: updated_content} end + + assert {:ok, %TermNode{children: [{0, %TermNode{content: ^updated_content}}]}} = + TermNode.update_child(term_node, 0, update_function) + end + + test "returns error if the child is not found" do + child = Fakes.term_node(id: "root.0") + term_node = Fakes.term_node(kind: :tuple, id: "root", children: [{0, child}]) + + assert {:error, :child_not_found} = + TermNode.update_child(term_node, 1, &Function.identity/1) + end + end +end diff --git a/test/support/fakes.ex b/test/support/fakes.ex index 72e23fbce..fca43523b 100644 --- a/test/support/fakes.ex +++ b/test/support/fakes.ex @@ -3,6 +3,25 @@ defmodule LiveDebugger.Fakes do Fake complex structures """ + def display_element(opts \\ []) do + %LiveDebugger.App.Utils.TermNode.DisplayElement{ + text: Keyword.get(opts, :text, "text"), + color: Keyword.get(opts, :color, "text-code-1") + } + end + + def term_node(opts \\ []) do + %LiveDebugger.App.Utils.TermNode{ + id: Keyword.get(opts, :id, "indexed"), + kind: Keyword.get(opts, :kind, :atom), + content: Keyword.get(opts, :content, [display_element()]), + children: Keyword.get(opts, :children, []), + expanded_before: Keyword.get(opts, :expanded_before, []), + expanded_after: Keyword.get(opts, :expanded_after, []), + open?: Keyword.get(opts, :open?, false) + } + end + def term_diff_primitive(opts \\ []) do old_value = Keyword.get(opts, :old_value, 1) new_value = Keyword.get(opts, :new_value, 2) From 94da57a070185c9a34ac68a09f03696747a3ffb6 Mon Sep 17 00:00:00 2001 From: Alan Guzek Date: Wed, 15 Oct 2025 16:27:20 +0200 Subject: [PATCH 09/15] small changes --- .../web/hook_components/assigns_display.ex | 4 +- lib/live_debugger/app/utils/term_parser.ex | 10 +- test/app/utils/term_parser_test.exs | 330 ++++++++++-------- 3 files changed, 200 insertions(+), 144 deletions(-) diff --git a/lib/live_debugger/app/debugger/node_state/web/hook_components/assigns_display.ex b/lib/live_debugger/app/debugger/node_state/web/hook_components/assigns_display.ex index a3f9fdaae..0084e3ac1 100644 --- a/lib/live_debugger/app/debugger/node_state/web/hook_components/assigns_display.ex +++ b/lib/live_debugger/app/debugger/node_state/web/hook_components/assigns_display.ex @@ -38,9 +38,7 @@ defmodule LiveDebugger.App.Debugger.NodeState.Web.HookComponents.AssignsDisplay with %AsyncResult{ok?: true, result: {node_assigns, term_node, copy_string}} <- socket.assigns.node_assigns_info, {:ok, updated_term_node} <- - TermParser.update_by_id(term_node, id, fn node -> - {:ok, %TermNode{node | open?: !node.open?}} - end) do + TermParser.update_by_id(term_node, id, &%TermNode{&1 | open?: !&1.open?}) do AsyncResult.ok({node_assigns, updated_term_node, copy_string}) else {:error, reason} -> diff --git a/lib/live_debugger/app/utils/term_parser.ex b/lib/live_debugger/app/utils/term_parser.ex index 985fd2d86..f9510d47e 100644 --- a/lib/live_debugger/app/utils/term_parser.ex +++ b/lib/live_debugger/app/utils/term_parser.ex @@ -52,12 +52,12 @@ defmodule LiveDebugger.App.Utils.TermParser do ...> end) {:ok, %TermNode{...}} """ - @spec update_by_id(TermNode.t(), String.t(), (TermNode.t() -> TermNode.ok_error())) :: + @spec update_by_id(TermNode.t(), String.t(), (TermNode.t() -> TermNode.t())) :: TermNode.ok_error() def update_by_id(term_node, path, update_fn) def update_by_id(term_node, "root", update_fn) do - update_fn.(term_node) + {:ok, update_fn.(term_node)} end def update_by_id(term_node, "root" <> _ = id, update_fn) do @@ -90,7 +90,7 @@ defmodule LiveDebugger.App.Utils.TermParser do {:error, "Invalid diff or term node: #{inspect(error)}"} end - @spec update_by_path(TermNode.t(), [integer()], (TermNode.t() -> TermNode.ok_error())) :: + @spec update_by_path(TermNode.t(), [integer()], (TermNode.t() -> TermNode.t())) :: TermNode.ok_error() defp update_by_path(term, [index | path], update_fn) do with {key, child} <- Enum.at(term.children, index), @@ -107,12 +107,14 @@ defmodule LiveDebugger.App.Utils.TermParser do end defp update_by_path(term, [], update_fn) do - update_fn.(term) + {:ok, update_fn.(term)} end @spec update_by_diff!(TermNode.t(), Diff.t(), Keyword.t()) :: TermNode.t() defp update_by_diff!(term_node, diff, opts \\ []) + defp update_by_diff!(term_node, %Diff{type: :equal}, _opts), do: term_node + defp update_by_diff!(_, %Diff{type: :primitive} = diff, opts) do term = TermDiffer.primitive_new_value(diff) diff --git a/test/app/utils/term_parser_test.exs b/test/app/utils/term_parser_test.exs index 2ae76f815..2a573d8db 100644 --- a/test/app/utils/term_parser_test.exs +++ b/test/app/utils/term_parser_test.exs @@ -4,6 +4,7 @@ defmodule LiveDebugger.App.Utils.TermParserTest do alias LiveDebugger.App.Utils.TermNode alias LiveDebugger.App.Utils.TermNode.DisplayElement alias LiveDebugger.App.Utils.TermParser + alias LiveDebugger.Fakes defmodule TestStruct do defstruct [:field1, :field2] @@ -66,27 +67,29 @@ defmodule LiveDebugger.App.Utils.TermParserTest do kind: :tuple, open?: true, children: [ - {0, %TermNode{ - id: "root.0", - kind: :atom, - open?: false, - children: [], - content: [ - %DisplayElement{text: ":ok", color: "text-code-1"}, - %DisplayElement{text: ",", color: "text-code-2"} - ], - expanded_before: [], - expanded_after: [%DisplayElement{text: ",", color: "text-code-2"}] - }}, - {1, %TermNode{ - id: "root.1", - kind: :binary, - open?: false, - children: [], - content: [%DisplayElement{text: "\"Hello\"", color: "text-code-4"}], - expanded_before: [], - expanded_after: [] - }} + {0, + %TermNode{ + id: "root.0", + kind: :atom, + open?: false, + children: [], + content: [ + %DisplayElement{text: ":ok", color: "text-code-1"}, + %DisplayElement{text: ",", color: "text-code-2"} + ], + expanded_before: [], + expanded_after: [%DisplayElement{text: ",", color: "text-code-2"}] + }}, + {1, + %TermNode{ + id: "root.1", + kind: :binary, + open?: false, + children: [], + content: [%DisplayElement{text: "\"Hello\"", color: "text-code-4"}], + expanded_before: [], + expanded_after: [] + }} ], content: [%DisplayElement{text: "{...}", color: "text-code-2"}], expanded_before: [%DisplayElement{text: "{", color: "text-code-2"}], @@ -120,39 +123,42 @@ defmodule LiveDebugger.App.Utils.TermParserTest do kind: :list, open?: true, children: [ - {0, %TermNode{ - id: "root.0", - kind: :number, - open?: false, - children: [], - content: [ - %DisplayElement{text: "1", color: "text-code-1"}, - %DisplayElement{text: ",", color: "text-code-2"} - ], - expanded_before: [], - expanded_after: [%DisplayElement{text: ",", color: "text-code-2"}] - }}, - {1, %TermNode{ - id: "root.1", - kind: :number, - open?: false, - children: [], - content: [ - %DisplayElement{text: "2", color: "text-code-1"}, - %DisplayElement{text: ",", color: "text-code-2"} - ], - expanded_before: [], - expanded_after: [%DisplayElement{text: ",", color: "text-code-2"}] - }}, - {2, %TermNode{ - id: "root.2", - kind: :number, - open?: false, - children: [], - content: [%DisplayElement{text: "3", color: "text-code-1"}], - expanded_before: [], - expanded_after: [] - }} + {0, + %TermNode{ + id: "root.0", + kind: :number, + open?: false, + children: [], + content: [ + %DisplayElement{text: "1", color: "text-code-1"}, + %DisplayElement{text: ",", color: "text-code-2"} + ], + expanded_before: [], + expanded_after: [%DisplayElement{text: ",", color: "text-code-2"}] + }}, + {1, + %TermNode{ + id: "root.1", + kind: :number, + open?: false, + children: [], + content: [ + %DisplayElement{text: "2", color: "text-code-1"}, + %DisplayElement{text: ",", color: "text-code-2"} + ], + expanded_before: [], + expanded_after: [%DisplayElement{text: ",", color: "text-code-2"}] + }}, + {2, + %TermNode{ + id: "root.2", + kind: :number, + open?: false, + children: [], + content: [%DisplayElement{text: "3", color: "text-code-1"}], + expanded_before: [], + expanded_after: [] + }} ], content: [%DisplayElement{text: "[...]", color: "text-code-2"}], expanded_before: [%DisplayElement{text: "[", color: "text-code-2"}], @@ -388,56 +394,59 @@ defmodule LiveDebugger.App.Utils.TermParserTest do kind: :map, open?: true, children: [ - {"key1", %TermNode{ - id: "root.0", - kind: :binary, - open?: false, - children: [], - content: [ - %DisplayElement{text: "\"key1\"", color: "text-code-4"}, - %DisplayElement{text: " => ", color: "text-code-2"}, - %DisplayElement{text: "\"value1\"", color: "text-code-4"}, - %DisplayElement{text: ",", color: "text-code-2"} - ], - expanded_before: [ - %DisplayElement{text: "\"key1\"", color: "text-code-4"}, - %DisplayElement{text: " => ", color: "text-code-2"} - ], - expanded_after: [%DisplayElement{text: ",", color: "text-code-2"}] - }}, - {"key2", %TermNode{ - id: "root.1", - kind: :number, - open?: false, - children: [], - content: [ - %DisplayElement{text: "\"key2\"", color: "text-code-4"}, - %DisplayElement{text: " => ", color: "text-code-2"}, - %DisplayElement{text: "42", color: "text-code-1"}, - %DisplayElement{text: ",", color: "text-code-2"} - ], - expanded_before: [ - %DisplayElement{text: "\"key2\"", color: "text-code-4"}, - %DisplayElement{text: " => ", color: "text-code-2"} - ], - expanded_after: [%DisplayElement{text: ",", color: "text-code-2"}] - }}, - {"key3", %TermNode{ - id: "root.2", - kind: :atom, - open?: false, - children: [], - content: [ - %DisplayElement{text: "\"key3\"", color: "text-code-4"}, - %DisplayElement{text: " => ", color: "text-code-2"}, - %DisplayElement{text: ":test", color: "text-code-1"} - ], - expanded_before: [ - %DisplayElement{text: "\"key3\"", color: "text-code-4"}, - %DisplayElement{text: " => ", color: "text-code-2"} - ], - expanded_after: [] - }} + {"key1", + %TermNode{ + id: "root.0", + kind: :binary, + open?: false, + children: [], + content: [ + %DisplayElement{text: "\"key1\"", color: "text-code-4"}, + %DisplayElement{text: " => ", color: "text-code-2"}, + %DisplayElement{text: "\"value1\"", color: "text-code-4"}, + %DisplayElement{text: ",", color: "text-code-2"} + ], + expanded_before: [ + %DisplayElement{text: "\"key1\"", color: "text-code-4"}, + %DisplayElement{text: " => ", color: "text-code-2"} + ], + expanded_after: [%DisplayElement{text: ",", color: "text-code-2"}] + }}, + {"key2", + %TermNode{ + id: "root.1", + kind: :number, + open?: false, + children: [], + content: [ + %DisplayElement{text: "\"key2\"", color: "text-code-4"}, + %DisplayElement{text: " => ", color: "text-code-2"}, + %DisplayElement{text: "42", color: "text-code-1"}, + %DisplayElement{text: ",", color: "text-code-2"} + ], + expanded_before: [ + %DisplayElement{text: "\"key2\"", color: "text-code-4"}, + %DisplayElement{text: " => ", color: "text-code-2"} + ], + expanded_after: [%DisplayElement{text: ",", color: "text-code-2"}] + }}, + {"key3", + %TermNode{ + id: "root.2", + kind: :atom, + open?: false, + children: [], + content: [ + %DisplayElement{text: "\"key3\"", color: "text-code-4"}, + %DisplayElement{text: " => ", color: "text-code-2"}, + %DisplayElement{text: ":test", color: "text-code-1"} + ], + expanded_before: [ + %DisplayElement{text: "\"key3\"", color: "text-code-4"}, + %DisplayElement{text: " => ", color: "text-code-2"} + ], + expanded_after: [] + }} ], content: [%DisplayElement{text: "%{...}", color: "text-code-2"}], expanded_before: [%DisplayElement{text: "%{", color: "text-code-2"}], @@ -460,39 +469,41 @@ defmodule LiveDebugger.App.Utils.TermParserTest do kind: :map, open?: true, children: [ - {{:ok, date}, %TermNode{ - id: "root.0", - kind: :binary, - open?: false, - children: [], - content: [ - %DisplayElement{text: "{:ok, ~D[2025-07-08]}", color: "text-code-2"}, - %DisplayElement{text: " => ", color: "text-code-2"}, - %DisplayElement{text: "\"Date\"", color: "text-code-4"}, - %DisplayElement{text: ",", color: "text-code-2"} - ], - expanded_before: [ - %DisplayElement{text: "{:ok, ~D[2025-07-08]}", color: "text-code-2"}, - %DisplayElement{text: " => ", color: "text-code-2"} - ], - expanded_after: [%DisplayElement{text: ",", color: "text-code-2"}] - }}, - {cid, %TermNode{ - id: "root.1", - kind: :binary, - open?: false, - children: [], - content: [ - %DisplayElement{text: "%Phoenix.LiveComponent.CID{cid: 1}", color: "text-code-1"}, - %DisplayElement{text: " => ", color: "text-code-2"}, - %DisplayElement{text: "\"CID\"", color: "text-code-4"} - ], - expanded_before: [ - %DisplayElement{text: "%Phoenix.LiveComponent.CID{cid: 1}", color: "text-code-1"}, - %DisplayElement{text: " => ", color: "text-code-2"} - ], - expanded_after: [] - }} + {{:ok, date}, + %TermNode{ + id: "root.0", + kind: :binary, + open?: false, + children: [], + content: [ + %DisplayElement{text: "{:ok, ~D[2025-07-08]}", color: "text-code-2"}, + %DisplayElement{text: " => ", color: "text-code-2"}, + %DisplayElement{text: "\"Date\"", color: "text-code-4"}, + %DisplayElement{text: ",", color: "text-code-2"} + ], + expanded_before: [ + %DisplayElement{text: "{:ok, ~D[2025-07-08]}", color: "text-code-2"}, + %DisplayElement{text: " => ", color: "text-code-2"} + ], + expanded_after: [%DisplayElement{text: ",", color: "text-code-2"}] + }}, + {cid, + %TermNode{ + id: "root.1", + kind: :binary, + open?: false, + children: [], + content: [ + %DisplayElement{text: "%Phoenix.LiveComponent.CID{cid: 1}", color: "text-code-1"}, + %DisplayElement{text: " => ", color: "text-code-2"}, + %DisplayElement{text: "\"CID\"", color: "text-code-4"} + ], + expanded_before: [ + %DisplayElement{text: "%Phoenix.LiveComponent.CID{cid: 1}", color: "text-code-1"}, + %DisplayElement{text: " => ", color: "text-code-2"} + ], + expanded_after: [] + }} ], content: [%DisplayElement{text: "%{...}", color: "text-code-2"}], expanded_before: [%DisplayElement{text: "%{", color: "text-code-2"}], @@ -536,4 +547,49 @@ defmodule LiveDebugger.App.Utils.TermParserTest do "%{pid: :erlang.list_to_pid(~c\"<0.123.0>\")}" end end + + describe "update_by_id/3" do + test "updates root node" do + term_node = Fakes.term_node(id: "root", open?: true) + + assert {:ok, %TermNode{open?: false}} = + TermParser.update_by_id(term_node, "root", &close_term_node/1) + end + + test "updates child node" do + child = Fakes.term_node(id: "root.0", open?: true) + term_node = Fakes.term_node(id: "root", open?: true, children: [{0, child}]) + + assert {:ok, %TermNode{open?: true, children: [{0, %TermNode{open?: false}}]}} = + TermParser.update_by_id(term_node, "root.0", &close_term_node/1) + end + + test "returns error if the term node is not found" do + term_node = Fakes.term_node(id: "root", open?: true) + + assert {:error, :child_not_found} = + TermParser.update_by_id(term_node, "root.1", &close_term_node/1) + end + end + + describe "update_by_diff/2" do + test "doesn't update if type is equal" do + end + + test "updates nested elements" do + end + + test "properly adds suffixes" do + end + + test "properly opens lists and tuples within default limits" do + end + + test "returns error if the term node and diff are not complatible" do + end + end + + defp close_term_node(term_node) do + %TermNode{term_node | open?: false} + end end From 5451e37c7a77e47d5c09c6fb78cfab291c8f90c5 Mon Sep 17 00:00:00 2001 From: Alan Guzek Date: Wed, 15 Oct 2025 17:03:39 +0200 Subject: [PATCH 10/15] add tests for updating term node by diff --- lib/live_debugger/app/utils/term_differ.ex | 2 +- test/app/utils/term_parser_test.exs | 211 ++++++++++++++++++++- 2 files changed, 209 insertions(+), 4 deletions(-) diff --git a/lib/live_debugger/app/utils/term_differ.ex b/lib/live_debugger/app/utils/term_differ.ex index d82a63d48..12cc213b8 100644 --- a/lib/live_debugger/app/utils/term_differ.ex +++ b/lib/live_debugger/app/utils/term_differ.ex @@ -54,7 +54,7 @@ defmodule LiveDebugger.App.Utils.TermDiffer do Struct for representing a diff between two terms. """ - defstruct [:type, ins: %{}, del: %{}, diff: %{}] + defstruct type: :equal, ins: %{}, del: %{}, diff: %{} @type type() :: :map | :list | :tuple | :struct | :primitive | :equal @type key() :: any() diff --git a/test/app/utils/term_parser_test.exs b/test/app/utils/term_parser_test.exs index 2a573d8db..2a7bbfbb0 100644 --- a/test/app/utils/term_parser_test.exs +++ b/test/app/utils/term_parser_test.exs @@ -4,6 +4,8 @@ defmodule LiveDebugger.App.Utils.TermParserTest do alias LiveDebugger.App.Utils.TermNode alias LiveDebugger.App.Utils.TermNode.DisplayElement alias LiveDebugger.App.Utils.TermParser + alias LiveDebugger.App.Utils.TermDiffer + alias LiveDebugger.App.Utils.TermDiffer.Diff alias LiveDebugger.Fakes defmodule TestStruct do @@ -574,18 +576,221 @@ defmodule LiveDebugger.App.Utils.TermParserTest do describe "update_by_diff/2" do test "doesn't update if type is equal" do + term = %{a: 1, b: [1, 2, 3]} + term_node = TermParser.term_to_display_tree(term) + + diff = %Diff{type: :equal} + + assert {:ok, ^term_node} = TermParser.update_by_diff(term_node, diff) + end + + test "updates primitive values" do + old_term = "Old Term" + new_term = "New Term" + + term_node = %TermNode{content: [%DisplayElement{text: "Old Term", color: "text-code-1"}]} + + diff = TermDiffer.diff(old_term, new_term) + + assert {:ok, + %TermNode{content: [%DisplayElement{text: "\"New Term\"", color: "text-code-4"}]}} = + TermParser.update_by_diff(term_node, diff) end - test "updates nested elements" do + test "updates nested elements in maps" do + old_term = %{ + user: %{ + name: "Alice", + settings: %{ + theme: "light", + notifications: true + } + } + } + + new_term = %{ + user: %{ + name: "Alice", + settings: %{ + theme: "dark", + notifications: false + } + } + } + + term_node = TermParser.term_to_display_tree(old_term) + diff = TermDiffer.diff(old_term, new_term) + + assert {:ok, updated_node} = TermParser.update_by_diff(term_node, diff) + + {_, user_node} = Enum.find(updated_node.children, fn {key, _} -> key == :user end) + {_, settings_node} = Enum.find(user_node.children, fn {key, _} -> key == :settings end) + {_, theme_node} = Enum.find(settings_node.children, fn {key, _} -> key == :theme end) + + assert theme_node.content == [ + %DisplayElement{text: "theme:", color: "text-code-1"}, + %DisplayElement{text: " ", color: "text-code-2"}, + %DisplayElement{text: "\"dark\"", color: "text-code-4"} + ] end - test "properly adds suffixes" do + test "handles list insertions and deletions" do + old_term = [1, 2, 3] + new_term = [0, 2, 3, 4] + + term_node = TermParser.term_to_display_tree(old_term) + diff = TermDiffer.diff(old_term, new_term) + + assert {:ok, updated_node} = TermParser.update_by_diff(term_node, diff) + + assert length(updated_node.children) == 4 + + assert {0, + %TermNode{ + content: [ + %DisplayElement{text: "0", color: "text-code-1"}, + %DisplayElement{text: ",", color: "text-code-2"} + ] + }} = Enum.at(updated_node.children, 0) + + assert {3, + %TermNode{ + content: [ + %DisplayElement{text: "4", color: "text-code-1"} + ] + }} = Enum.at(updated_node.children, 3) + end + + test "properly adds and removes comma suffixes" do + term_node1 = %{a: 1, b: 2} + term_node2 = %{a: 1, b: 2, c: 3} + + term_node = TermParser.term_to_display_tree(term_node1) + diff = TermDiffer.diff(term_node1, term_node2) + + assert {:ok, updated_node} = TermParser.update_by_diff(term_node, diff) + + {_, b_node} = Enum.find(updated_node.children, fn {key, _} -> key == :b end) + assert List.last(b_node.content) == %DisplayElement{text: ",", color: "text-code-2"} + assert List.last(b_node.expanded_after) == %DisplayElement{text: ",", color: "text-code-2"} + + term_node3 = %{a: 1, b: 2} + diff = TermDiffer.diff(term_node2, term_node3) + + assert {:ok, final_node} = TermParser.update_by_diff(updated_node, diff) + + {_, b_node} = Enum.find(final_node.children, fn {key, _} -> key == :b end) + refute List.last(b_node.content) == %DisplayElement{text: ",", color: "text-code-2"} + refute List.last(b_node.expanded_after) == %DisplayElement{text: ",", color: "text-code-2"} end test "properly opens lists and tuples within default limits" do + old_term = %{small: [1], large: [1, 2, 3, 4]} + new_term = %{small: [1, 2], large: [1, 2, 3, 4, 5]} + + term_node = TermParser.term_to_display_tree(old_term) + diff = TermDiffer.diff(old_term, new_term) + + assert {:ok, updated_node} = TermParser.update_by_diff(term_node, diff) + + assert updated_node.open? == true + + assert {_, %TermNode{open?: true}} = + Enum.find(updated_node.children, fn {key, _} -> key == :small end) + + assert {_, %TermNode{open?: false}} = + Enum.find(updated_node.children, fn {key, _} -> key == :large end) + end + + test "handles struct updates" do + old_term = %TestStruct{field1: "old", field2: 1} + new_term = %TestStruct{field1: "new", field2: 2} + + term_node = TermParser.term_to_display_tree(old_term) + diff = TermDiffer.diff(old_term, new_term) + + assert {:ok, updated_node} = TermParser.update_by_diff(term_node, diff) + + {_, field1_node} = Enum.find(updated_node.children, fn {key, _} -> key == :field1 end) + + assert field1_node.content == [ + %DisplayElement{text: "field1:", color: "text-code-1"}, + %DisplayElement{text: " ", color: "text-code-2"}, + %DisplayElement{text: "\"new\"", color: "text-code-4"}, + %DisplayElement{text: ",", color: "text-code-2"} + ] + + {_, field2_node} = Enum.find(updated_node.children, fn {key, _} -> key == :field2 end) + + assert field2_node.content == [ + %DisplayElement{text: "field2:", color: "text-code-1"}, + %DisplayElement{text: " ", color: "text-code-2"}, + %DisplayElement{text: "2", color: "text-code-1"} + ] end - test "returns error if the term node and diff are not complatible" do + test "handles map key additions and deletions" do + old_term = %{"a" => 1, "b" => 2} + new_term = %{"b" => 2, "d" => 4} + + term_node = TermParser.term_to_display_tree(old_term) + diff = TermDiffer.diff(old_term, new_term) + + assert {:ok, updated_node} = TermParser.update_by_diff(term_node, diff) + + refute Enum.any?(updated_node.children, fn {key, _} -> key == "a" end) + + assert {_, d_node} = Enum.find(updated_node.children, fn {key, _} -> key == "d" end) + + assert d_node.content == [ + %DisplayElement{text: "\"d\"", color: "text-code-4"}, + %DisplayElement{text: " => ", color: "text-code-2"}, + %DisplayElement{text: "4", color: "text-code-1"} + ] + end + + test "correctly updates node ids" do + old_term = %{items: [1, 2], metadata: %{version: 1}} + new_term = %{items: [1, 2, 3], metadata: %{version: 2}} + + term_node = TermParser.term_to_display_tree(old_term) + diff = TermDiffer.diff(old_term, new_term) + + assert {:ok, + %TermNode{ + id: "root", + children: [ + {:items, + %TermNode{ + id: "root.0", + children: [ + {0, %TermNode{id: "root.0.0"}}, + {1, %TermNode{id: "root.0.1"}}, + {2, %TermNode{id: "root.0.2"}} + ] + }}, + {:metadata, + %TermNode{ + id: "root.1", + children: [ + {:version, %TermNode{id: "root.1.0"}} + ] + }} + ] + }} = TermParser.update_by_diff(term_node, diff) + end + + test "returns error if the term node and diff are not compatible" do + invalid_diff = %Diff{ + type: :map, + diff: %{non_existent_key: Fakes.term_diff_primitive()} + } + + old_term = :not_a_map + + term_node = TermParser.term_to_display_tree(old_term) + + assert {:error, _} = TermParser.update_by_diff(term_node, invalid_diff) end end From 4b1c789616899646cdfdc481792d846a04107965 Mon Sep 17 00:00:00 2001 From: Alan Guzek Date: Wed, 15 Oct 2025 17:13:53 +0200 Subject: [PATCH 11/15] expands cannot be nil --- lib/live_debugger/app/utils/term_node.ex | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/live_debugger/app/utils/term_node.ex b/lib/live_debugger/app/utils/term_node.ex index b4cb37fd6..8271c5bae 100644 --- a/lib/live_debugger/app/utils/term_node.ex +++ b/lib/live_debugger/app/utils/term_node.ex @@ -44,8 +44,8 @@ defmodule LiveDebugger.App.Utils.TermNode do open?: boolean(), children: [{any(), t()}], content: [DisplayElement.t()], - expanded_before: [DisplayElement.t()] | nil, - expanded_after: [DisplayElement.t()] | nil + expanded_before: [DisplayElement.t()], + expanded_after: [DisplayElement.t()] } @type ok_error() :: {:ok, t()} | {:error, any()} From b026151c4fbaec2954cc3e82f7c477bce53d38ab Mon Sep 17 00:00:00 2001 From: Alan Guzek Date: Wed, 15 Oct 2025 17:18:48 +0200 Subject: [PATCH 12/15] better code --- .../node_state/web/hooks/node_assigns.ex | 41 +++++++++---------- 1 file changed, 19 insertions(+), 22 deletions(-) diff --git a/lib/live_debugger/app/debugger/node_state/web/hooks/node_assigns.ex b/lib/live_debugger/app/debugger/node_state/web/hooks/node_assigns.ex index 35a2eb504..c21faa273 100644 --- a/lib/live_debugger/app/debugger/node_state/web/hooks/node_assigns.ex +++ b/lib/live_debugger/app/debugger/node_state/web/hooks/node_assigns.ex @@ -74,29 +74,26 @@ defmodule LiveDebugger.App.Debugger.NodeState.Web.Hooks.NodeAssigns do } = socket ) do - case TermDiffer.diff(old_assigns, node_assigns) do - %Diff{type: :equal} -> - node_assigns_info = AsyncResult.ok(socket.assigns.node_assigns_info.result) - {:halt, assign(socket, :node_assigns_info, node_assigns_info)} - - diff -> - copy_string = TermParser.term_to_copy_string(node_assigns) - - case TermParser.update_by_diff(old_term_node, diff) do - {:ok, term_node} -> - socket - |> assign(:node_assigns_info, AsyncResult.ok({node_assigns, term_node, copy_string})) - |> halt() - - {:error, reason} -> - socket - |> assign( - :node_assigns_info, + node_assigns_info = + case TermDiffer.diff(old_assigns, node_assigns) do + %Diff{type: :equal} -> + AsyncResult.ok(socket.assigns.node_assigns_info.result) + + diff -> + copy_string = TermParser.term_to_copy_string(node_assigns) + + case TermParser.update_by_diff(old_term_node, diff) do + {:ok, term_node} -> + AsyncResult.ok({node_assigns, term_node, copy_string}) + + {:error, reason} -> AsyncResult.failed(socket.assigns.node_assigns_info, reason) - ) - |> halt() - end - end + end + end + + socket + |> assign(:node_assigns_info, node_assigns_info) + |> halt() end defp handle_async(:fetch_node_assigns, {:ok, {:ok, node_assigns}}, socket) do From aab323d95fe76f1a69533830225272620bd946fb Mon Sep 17 00:00:00 2001 From: Alan Guzek Date: Fri, 17 Oct 2025 11:04:32 +0200 Subject: [PATCH 13/15] add big messages in assigns --- dev/live_views/messages.ex | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/dev/live_views/messages.ex b/dev/live_views/messages.ex index c5c075c8c..bd7368ae2 100644 --- a/dev/live_views/messages.ex +++ b/dev/live_views/messages.ex @@ -1,11 +1,20 @@ defmodule LiveDebuggerDev.LiveViews.Messages do use DevWeb, :live_view + def mount(_params, _session, socket) do + socket + |> assign(:messages, []) + |> ok() + end + + attr(:messages, :list, required: true) + def render(assigns) do ~H""" <.box title="Messages [LiveView]" color="purple">
<.button phx-click="big-message" color="purple">Send big message +
Message count: <%= length(@messages) %>
""" @@ -13,7 +22,12 @@ defmodule LiveDebuggerDev.LiveViews.Messages do def handle_event("big-message", _, socket) do send(self(), very_big_message()) - {:noreply, socket} + + socket + |> assign(:messages, [ + {length(socket.assigns.messages), very_big_message()} | socket.assigns.messages + ]) + |> noreply() end def handle_info(_, socket) do From 84b03c107fcbcb0dd3ee703d6fd3539c8b8fc7c5 Mon Sep 17 00:00:00 2001 From: Alan Guzek Date: Fri, 17 Oct 2025 11:09:25 +0200 Subject: [PATCH 14/15] Fix empty to non-empty term nodes --- lib/live_debugger/app/utils/term_parser.ex | 17 ++++++++++++++--- test/app/utils/term_parser_test.exs | 12 ++++++------ 2 files changed, 20 insertions(+), 9 deletions(-) diff --git a/lib/live_debugger/app/utils/term_parser.ex b/lib/live_debugger/app/utils/term_parser.ex index f9510d47e..6a6bbd4b2 100644 --- a/lib/live_debugger/app/utils/term_parser.ex +++ b/lib/live_debugger/app/utils/term_parser.ex @@ -251,7 +251,11 @@ defmodule LiveDebugger.App.Utils.TermParser do end defp to_node({}) do - TermNode.new(:tuple, [DisplayElement.black("{}")]) + TermNode.new(:tuple, [DisplayElement.black("{}")], + open?: false, + expanded_before: [DisplayElement.black("{")], + expanded_after: [DisplayElement.black("}")] + ) end defp to_node(tuple) when is_tuple(tuple) do @@ -265,7 +269,11 @@ defmodule LiveDebugger.App.Utils.TermParser do end defp to_node([]) do - TermNode.new(:list, [DisplayElement.black("[]")]) + TermNode.new(:list, [DisplayElement.black("[]")], + open?: false, + expanded_before: [DisplayElement.black("[")], + expanded_after: [DisplayElement.black("]")] + ) end defp to_node(list) when is_list(list) do @@ -319,7 +327,10 @@ defmodule LiveDebugger.App.Utils.TermParser do end defp to_node(%{} = map) when map_size(map) == 0 do - TermNode.new(:map, [DisplayElement.black("%{}")]) + TermNode.new(:map, [DisplayElement.black("%{}")], + expanded_before: [DisplayElement.black("%{")], + expanded_after: [DisplayElement.black("}")] + ) end defp to_node(map) when is_map(map) do diff --git a/test/app/utils/term_parser_test.exs b/test/app/utils/term_parser_test.exs index 2a7bbfbb0..90233703a 100644 --- a/test/app/utils/term_parser_test.exs +++ b/test/app/utils/term_parser_test.exs @@ -110,8 +110,8 @@ defmodule LiveDebugger.App.Utils.TermParserTest do open?: true, children: [], content: [%DisplayElement{text: "{}", color: "text-code-2"}], - expanded_before: [], - expanded_after: [] + expanded_before: [%DisplayElement{text: "{", color: "text-code-2"}], + expanded_after: [%DisplayElement{text: "}", color: "text-code-2"}] } assert TermParser.term_to_display_tree(term) == expected @@ -179,8 +179,8 @@ defmodule LiveDebugger.App.Utils.TermParserTest do open?: true, children: [], content: [%DisplayElement{text: "[]", color: "text-code-2"}], - expanded_before: [], - expanded_after: [] + expanded_before: [%DisplayElement{text: "[", color: "text-code-2"}], + expanded_after: [%DisplayElement{text: "]", color: "text-code-2"}] } assert TermParser.term_to_display_tree(term) == expected @@ -229,8 +229,8 @@ defmodule LiveDebugger.App.Utils.TermParserTest do open?: true, children: [], content: [%DisplayElement{text: "%{}", color: "text-code-2"}], - expanded_before: [], - expanded_after: [] + expanded_before: [%DisplayElement{text: "%{", color: "text-code-2"}], + expanded_after: [%DisplayElement{text: "}", color: "text-code-2"}] } assert TermParser.term_to_display_tree(term) == expected From 70fe6fe899834688059cf97b7044863dd0515520 Mon Sep 17 00:00:00 2001 From: Alan Guzek Date: Fri, 17 Oct 2025 11:10:43 +0200 Subject: [PATCH 15/15] split hook component to component and hook --- .../app/debugger/node_state/web/components.ex | 6 +- .../web/hook_components/assigns_display.ex | 100 ------------------ .../node_state/web/hooks/term_node_toggle.ex | 44 ++++++++ .../node_state/web/node_state_live.ex | 2 +- .../debugger/web/components/elixir_display.ex | 44 +++++++- 5 files changed, 91 insertions(+), 105 deletions(-) delete mode 100644 lib/live_debugger/app/debugger/node_state/web/hook_components/assigns_display.ex create mode 100644 lib/live_debugger/app/debugger/node_state/web/hooks/term_node_toggle.ex 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 7ec49586f..44c1f86d7 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.NodeState.Web.HookComponents.AssignsDisplay + alias LiveDebugger.App.Debugger.Web.Components.ElixirDisplay alias LiveDebugger.App.Debugger.NodeState.Web.HookComponents.AssignsSearch alias LiveDebugger.App.Utils.TermNode alias LiveDebugger.Utils.Memory @@ -54,7 +54,7 @@ defmodule LiveDebugger.App.Debugger.NodeState.Web.Components do
<.assigns_size_label assigns={@assigns} id="display-container-size-label" />
- +
<.fullscreen id={@fullscreen_id} title="Assigns"> @@ -72,7 +72,7 @@ defmodule LiveDebugger.App.Debugger.NodeState.Web.Components do
<.assigns_size_label assigns={@assigns} id="display-fullscreen-size-label" />
- + diff --git a/lib/live_debugger/app/debugger/node_state/web/hook_components/assigns_display.ex b/lib/live_debugger/app/debugger/node_state/web/hook_components/assigns_display.ex deleted file mode 100644 index 0084e3ac1..000000000 --- a/lib/live_debugger/app/debugger/node_state/web/hook_components/assigns_display.ex +++ /dev/null @@ -1,100 +0,0 @@ -defmodule LiveDebugger.App.Debugger.NodeState.Web.HookComponents.AssignsDisplay do - @moduledoc """ - 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, :hook_component - - alias LiveDebugger.App.Debugger.Web.Components.ElixirDisplay - alias LiveDebugger.App.Utils.TermNode - alias LiveDebugger.App.Utils.TermParser - alias Phoenix.LiveView.AsyncResult - - @required_assigns [:node_assigns_info] - - @impl true - def init(socket) do - socket - |> check_assigns!(@required_assigns) - |> attach_hook(:assigns_display, :handle_event, &handle_event/3) - |> register_hook(:assigns_display) - end - - attr(:id, :string, required: true) - attr(:node, TermNode, required: true) - - @impl true - def render(assigns) do - ~H""" -
- <.term node={@node} /> -
- """ - end - - defp handle_event("toggle_node", %{"id" => id}, socket) do - node_assigns_info = - with %AsyncResult{ok?: true, result: {node_assigns, term_node, copy_string}} <- - socket.assigns.node_assigns_info, - {:ok, updated_term_node} <- - TermParser.update_by_id(term_node, id, &%TermNode{&1 | open?: !&1.open?}) do - AsyncResult.ok({node_assigns, updated_term_node, copy_string}) - else - {:error, reason} -> - AsyncResult.failed(socket.assigns.node_assigns_info, reason) - - _ -> - socket.assigns.node_assigns_info - end - - socket - |> assign(:node_assigns_info, node_assigns_info) - |> halt() - end - - defp handle_event("toggle_node", _, socket), do: {:halt, socket} - defp handle_event(_, _, socket), do: {:cont, socket} - - attr(:node, TermNode, 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} - > - <:label :let={open}> - <%= if open do %> - - <% else %> - - <% end %> - -
    -
  1. - <.term node={child} /> -
  2. -
-
- -
- - <% else %> -
- -
- <% end %> -
- """ - end -end diff --git a/lib/live_debugger/app/debugger/node_state/web/hooks/term_node_toggle.ex b/lib/live_debugger/app/debugger/node_state/web/hooks/term_node_toggle.ex new file mode 100644 index 000000000..36f974062 --- /dev/null +++ b/lib/live_debugger/app/debugger/node_state/web/hooks/term_node_toggle.ex @@ -0,0 +1,44 @@ +defmodule LiveDebugger.App.Debugger.NodeState.Web.Hooks.TermNodeToggle do + @moduledoc """ + Hook that can be used to toggle the open state of a `assigns` term node. + """ + + use LiveDebugger.App.Web, :hook + + alias LiveDebugger.App.Utils.TermNode + alias LiveDebugger.App.Utils.TermParser + alias Phoenix.LiveView.AsyncResult + + @required_assigns [:node_assigns_info] + + @spec init(Phoenix.LiveView.Socket.t()) :: Phoenix.LiveView.Socket.t() + def init(socket) do + socket + |> check_assigns!(@required_assigns) + |> attach_hook(:term_node_toggle, :handle_event, &handle_event/3) + |> register_hook(:term_node_toggle) + end + + defp handle_event("toggle_node", %{"id" => id}, socket) do + node_assigns_info = + with %AsyncResult{ok?: true, result: {node_assigns, term_node, copy_string}} <- + socket.assigns.node_assigns_info, + {:ok, updated_term_node} <- + TermParser.update_by_id(term_node, id, &%TermNode{&1 | open?: !&1.open?}) do + AsyncResult.ok({node_assigns, updated_term_node, copy_string}) + else + {:error, reason} -> + AsyncResult.failed(socket.assigns.node_assigns_info, reason) + + _ -> + socket.assigns.node_assigns_info + end + + socket + |> assign(:node_assigns_info, node_assigns_info) + |> halt() + end + + defp handle_event("toggle_node", _, socket), do: {:halt, socket} + defp handle_event(_, _, socket), do: {:cont, socket} +end diff --git a/lib/live_debugger/app/debugger/node_state/web/node_state_live.ex b/lib/live_debugger/app/debugger/node_state/web/node_state_live.ex index 4904c7b74..ed6f05784 100644 --- a/lib/live_debugger/app/debugger/node_state/web/node_state_live.ex +++ b/lib/live_debugger/app/debugger/node_state/web/node_state_live.ex @@ -64,7 +64,7 @@ defmodule LiveDebugger.App.Debugger.NodeState.Web.NodeStateLive do |> assign(:node_id, node_id) |> assign(:assigns_search_phrase, "") |> Hooks.NodeAssigns.init() - |> HookComponents.AssignsDisplay.init() + |> Hooks.TermNodeToggle.init() |> HookComponents.AssignsSearch.init() |> ok() end 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 03947df7e..85d76542b 100644 --- a/lib/live_debugger/app/debugger/web/components/elixir_display.ex +++ b/lib/live_debugger/app/debugger/web/components/elixir_display.ex @@ -59,9 +59,51 @@ defmodule LiveDebugger.App.Debugger.Web.Components.ElixirDisplay do """ end + attr(:node, TermNode, required: true) + + def static_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} + > + <:label :let={open}> + <%= if open do %> + <.text_items items={@node.expanded_before} /> + <% else %> + <.text_items items={@node.content} /> + <% end %> + +
    +
  1. + <.static_term node={child} /> +
  2. +
+
+ <.text_items items={@node.expanded_after} /> +
+ + <% else %> +
+ <.text_items items={@node.content} /> +
+ <% end %> +
+ """ + end + attr(:items, :list, required: true) - def text_items(assigns) do + defp text_items(assigns) do ~H"""
<%= for item <- @items do %>