From dfe053f388f1070dbff5621d71e253f2aa18febb Mon Sep 17 00:00:00 2001 From: srzeszut Date: Mon, 23 Mar 2026 11:55:42 +0100 Subject: [PATCH 01/15] add suggested function matcher --- .../gen_servers/trace_handler.ex | 2 + lib/live_debugger/utils/function_matcher.ex | 202 ++++++++++++++++++ 2 files changed, 204 insertions(+) create mode 100644 lib/live_debugger/utils/function_matcher.ex diff --git a/lib/live_debugger/services/callback_tracer/gen_servers/trace_handler.ex b/lib/live_debugger/services/callback_tracer/gen_servers/trace_handler.ex index 57297f19f..053bb9c0f 100644 --- a/lib/live_debugger/services/callback_tracer/gen_servers/trace_handler.ex +++ b/lib/live_debugger/services/callback_tracer/gen_servers/trace_handler.ex @@ -105,6 +105,8 @@ defmodule LiveDebugger.Services.CallbackTracer.GenServers.TraceHandler do with {:ok, trace} <- TraceActions.create_trace(n, module, fun, args, pid, ts), {:ok, ref} <- TraceActions.persist_trace(trace), :ok <- TraceActions.publish_trace(trace, ref) do + dbg({module, fun, args}) + dbg({LiveDebugger.Utils.FunctionMatcher.find_clause_location(module, fun, args)}) {:noreply, put_trace_record(state, trace, ref, ts)} else {:error, "Transport PID is nil"} -> diff --git a/lib/live_debugger/utils/function_matcher.ex b/lib/live_debugger/utils/function_matcher.ex new file mode 100644 index 000000000..5ef7b1b14 --- /dev/null +++ b/lib/live_debugger/utils/function_matcher.ex @@ -0,0 +1,202 @@ +defmodule LiveDebugger.Utils.FunctionMatcher do + def find_matching_clause_line(module, function_name, args) do + with {:ok, patterns} <- get_function_patterns(module, function_name, length(args)), + {:ok, matching_clause} <- find_matching_pattern(patterns, args), + {:ok, file_path} <- get_module_file(module) do + {:ok, %{file: file_path, line: matching_clause.line}} + else + error -> error + end + end + + defp get_function_patterns(module, function_name, arity) do + try do + beam_file = :code.which(module) + {:ok, {^module, [{:debug_info, debug_info}]}} = :beam_lib.chunks(beam_file, [:debug_info]) + dbg(module.__info__(:attributes)) + # dbg(beam_file) + # dbg(debug_info) + # dbg(Code.fetch_docs(module)) + + case debug_info do + {:debug_info_v1, :elixir_erl, {_version, %{definitions: definitions}, _}} -> + dbg(definitions) + patterns = extract_function_clauses(definitions, function_name, arity) + + if Enum.empty?(patterns) do + {:error, :function_not_found} + else + {:ok, patterns} + end + + {:debug_info_v1, backend, metadata} -> + {:error, {:unsupported_backend, backend, metadata}} + + other -> + {:error, {:unsupported_debug_info_format, other}} + end + rescue + error -> {:error, {:exception, error}} + end + end + + defp extract_function_clauses(definitions, target_fun, target_arity) do + definitions + |> Enum.find_value([], fn + {{^target_fun, ^target_arity}, _kind, _meta, clauses} -> + dbg(clauses) + extract_clause_info(clauses) + dbg(extract_clause_info(clauses)) + + _ -> + false + end) + end + + defp extract_clause_info(clauses) do + clauses + |> Enum.with_index(1) + |> Enum.map(fn {{meta, args, _guards, _body}, clause_num} -> + %{ + clause: clause_num, + line: Keyword.get(meta, :line, 0), + patterns: args + } + end) + end + + defp find_matching_pattern(patterns, args) do + case Enum.find(patterns, &pattern_matches?(&1.patterns, args)) do + nil -> {:error, :no_matching_clause} + matching_clause -> {:ok, matching_clause} + end + end + + defp pattern_matches?(ast_patterns, real_args) do + try do + patterns = Enum.map(ast_patterns, &elixir_ast_to_pattern/1) + test_match(patterns, real_args) + rescue + _ -> false + end + end + + defp elixir_ast_to_pattern(ast) do + case ast do + atom when is_atom(atom) -> + atom + + number when is_number(number) -> + number + + string when is_binary(string) -> + string + + {var_name, _meta, nil} when is_atom(var_name) -> + :_ + + {:{}, _meta, elements} -> + elements + |> Enum.map(&elixir_ast_to_pattern/1) + |> List.to_tuple() + + {left, right} -> + {elixir_ast_to_pattern(left), elixir_ast_to_pattern(right)} + + list when is_list(list) -> + Enum.map(list, &elixir_ast_to_pattern/1) + + {:%{}, _meta, fields} -> + fields + |> Enum.map(fn {key, value} -> + {elixir_ast_to_pattern(key), elixir_ast_to_pattern(value)} + end) + |> Map.new() + + {:%, _meta, [struct_name, {:%{}, _, fields}]} -> + field_map = + fields + |> Enum.map(fn {key, value} -> + {elixir_ast_to_pattern(key), elixir_ast_to_pattern(value)} + end) + |> Map.new() + + {:__struct__, struct_name, field_map} + + {:^, _meta, [var]} -> + elixir_ast_to_pattern(var) + + _ -> + :_ + end + end + + defp test_match(patterns, args) when length(patterns) != length(args), do: false + + defp test_match(patterns, args) do + Enum.zip(patterns, args) + |> Enum.all?(fn {pattern, arg} -> matches_value?(pattern, arg) end) + end + + defp matches_value?(:_, _), do: true + defp matches_value?(pattern, value) when pattern == value, do: true + + defp matches_value?({:__struct__, struct_name, field_map}, value) when is_struct(value) do + value.__struct__ == struct_name and + Enum.all?(field_map, fn {k, v} -> + matches_value?(v, Map.get(value, k)) + end) + end + + defp matches_value?(pattern_tuple, value_tuple) + when is_tuple(pattern_tuple) and is_tuple(value_tuple) do + if tuple_size(pattern_tuple) == tuple_size(value_tuple) do + pattern_list = Tuple.to_list(pattern_tuple) + value_list = Tuple.to_list(value_tuple) + test_match(pattern_list, value_list) + else + false + end + end + + defp matches_value?(pattern_map, value_map) when is_map(pattern_map) and is_map(value_map) do + Enum.all?(pattern_map, fn {k, v} -> + Map.has_key?(value_map, k) and matches_value?(v, Map.get(value_map, k)) + end) + end + + defp matches_value?(pattern_list, value_list) + when is_list(pattern_list) and is_list(value_list) do + length(pattern_list) == length(value_list) and + test_match(pattern_list, value_list) + end + + defp matches_value?(_, _), do: false + # refactor + defp get_module_file(module) do + case :code.which(module) do + :non_existing -> + {:error, :module_file_not_found} + + beam_file -> + source_file = get_source_file_from_beam(beam_file) + {:ok, source_file} + end + end + + defp get_source_file_from_beam(beam_file) do + try do + {:ok, {_module, [{:compile_info, compile_info}]}} = + :beam_lib.chunks(beam_file, [:compile_info]) + + case Keyword.get(compile_info, :source) do + # Fallback to beam file + nil -> List.to_string(beam_file) + source_path -> List.to_string(source_path) + end + rescue + # Fallback to beam file + _ -> List.to_string(beam_file) + end + end +end From 7f06aa89e2a1a286f1309fb7e75af7fc788c394e Mon Sep 17 00:00:00 2001 From: srzeszut Date: Mon, 23 Mar 2026 17:01:53 +0100 Subject: [PATCH 02/15] change function matcher approach --- dev/live_views/main.ex | 4 + .../gen_servers/trace_handler.ex | 4 +- lib/live_debugger/utils/function_matcher.ex | 249 ++++++------------ 3 files changed, 80 insertions(+), 177 deletions(-) diff --git a/dev/live_views/main.ex b/dev/live_views/main.ex index 6fad4bf72..18fef38d3 100644 --- a/dev/live_views/main.ex +++ b/dev/live_views/main.ex @@ -123,6 +123,10 @@ defmodule LiveDebuggerDev.LiveViews.Main do {:noreply, assign(socket, :message, %{name: "message name", text: "some text"})} end + def handle_event("increment", _, socket) when is_binary(socket) do + {:noreply, update(socket, :counter, &(&1 + 1))} + end + def handle_event("increment", _, socket) do {:noreply, update(socket, :counter, &(&1 + 1))} end diff --git a/lib/live_debugger/services/callback_tracer/gen_servers/trace_handler.ex b/lib/live_debugger/services/callback_tracer/gen_servers/trace_handler.ex index 053bb9c0f..fe1b22bcf 100644 --- a/lib/live_debugger/services/callback_tracer/gen_servers/trace_handler.ex +++ b/lib/live_debugger/services/callback_tracer/gen_servers/trace_handler.ex @@ -105,8 +105,8 @@ defmodule LiveDebugger.Services.CallbackTracer.GenServers.TraceHandler do with {:ok, trace} <- TraceActions.create_trace(n, module, fun, args, pid, ts), {:ok, ref} <- TraceActions.persist_trace(trace), :ok <- TraceActions.publish_trace(trace, ref) do - dbg({module, fun, args}) - dbg({LiveDebugger.Utils.FunctionMatcher.find_clause_location(module, fun, args)}) + # dbg({module, fun, args}) + dbg({LiveDebugger.Utils.FunctionMatcher.find_matching_clause_line(module, fun, args)}) {:noreply, put_trace_record(state, trace, ref, ts)} else {:error, "Transport PID is nil"} -> diff --git a/lib/live_debugger/utils/function_matcher.ex b/lib/live_debugger/utils/function_matcher.ex index 5ef7b1b14..c99aaa67b 100644 --- a/lib/live_debugger/utils/function_matcher.ex +++ b/lib/live_debugger/utils/function_matcher.ex @@ -1,202 +1,101 @@ defmodule LiveDebugger.Utils.FunctionMatcher do + @moduledoc """ + https://github.com/elixir-lang/elixir/blob/v1.20.0-rc.3/lib/elixir/lib/exception.ex#L245 + """ + def find_matching_clause_line(module, function_name, args) do - with {:ok, patterns} <- get_function_patterns(module, function_name, length(args)), - {:ok, matching_clause} <- find_matching_pattern(patterns, args), + with {:ok, clauses} <- get_function_clauses(module, function_name, length(args)), + {:ok, matching} <- find_matching(clauses, args), {:ok, file_path} <- get_module_file(module) do - {:ok, %{file: file_path, line: matching_clause.line}} - else - error -> error + {:ok, %{file: file_path, line: matching.line}} end end - defp get_function_patterns(module, function_name, arity) do - try do - beam_file = :code.which(module) - {:ok, {^module, [{:debug_info, debug_info}]}} = :beam_lib.chunks(beam_file, [:debug_info]) - dbg(module.__info__(:attributes)) - # dbg(beam_file) - # dbg(debug_info) - # dbg(Code.fetch_docs(module)) - - case debug_info do - {:debug_info_v1, :elixir_erl, {_version, %{definitions: definitions}, _}} -> - dbg(definitions) - patterns = extract_function_clauses(definitions, function_name, arity) - - if Enum.empty?(patterns) do - {:error, :function_not_found} - else - {:ok, patterns} - end - - {:debug_info_v1, backend, metadata} -> - {:error, {:unsupported_backend, backend, metadata}} - - other -> - {:error, {:unsupported_debug_info_format, other}} - end - rescue - error -> {:error, {:exception, error}} + defp get_function_clauses(module, function_name, arity) do + with [_ | _] = path <- :code.which(module), + {:ok, {_, [debug_info: debug_info]}} <- :beam_lib.chunks(path, [:debug_info]), + {:debug_info_v1, backend, data} <- debug_info, + {:ok, %{definitions: defs}} <- backend.debug_info(:elixir_v1, module, data, []), + {_, _kind, _, clauses} <- List.keyfind(defs, {function_name, arity}, 0) do + enriched = + for {meta, ex_args, guards, _body} <- clauses do + scope = :elixir_erl.scope(meta, true) + ann = :elixir_erl.get_ann(meta) + + {erl_args, scope} = + :elixir_erl_clauses.match( + ann, + &:elixir_erl_pass.translate_args/3, + ex_args, + scope + ) + + erl_guards = + Enum.map(guards, fn guard -> + {erl_guard, _scope} = :elixir_erl_pass.translate(guard, ann, scope) + erl_guard + end) + + %{ + line: Keyword.get(meta, :line, 0), + erl_args: erl_args, + erl_guards: erl_guards + } + end + + {:ok, enriched} + else + _ -> {:error, :cannot_read_debug_info} end end - defp extract_function_clauses(definitions, target_fun, target_arity) do - definitions - |> Enum.find_value([], fn - {{^target_fun, ^target_arity}, _kind, _meta, clauses} -> - dbg(clauses) - extract_clause_info(clauses) - dbg(extract_clause_info(clauses)) - - _ -> - false - end) - end - - defp extract_clause_info(clauses) do - clauses - |> Enum.with_index(1) - |> Enum.map(fn {{meta, args, _guards, _body}, clause_num} -> - %{ - clause: clause_num, - line: Keyword.get(meta, :line, 0), - patterns: args - } + defp find_matching(clauses, args) do + Enum.find_value(clauses, {:error, :no_matching_clause}, fn clause -> + case try_match_clause(clause, args) do + true -> {:ok, clause} + false -> false + end end) end - defp find_matching_pattern(patterns, args) do - case Enum.find(patterns, &pattern_matches?(&1.patterns, args)) do - nil -> {:error, :no_matching_clause} - matching_clause -> {:ok, matching_clause} - end - end - - defp pattern_matches?(ast_patterns, real_args) do + defp try_match_clause(clause, args) do try do - patterns = Enum.map(ast_patterns, &elixir_ast_to_pattern/1) - test_match(patterns, real_args) - rescue - _ -> false - end - end - - defp elixir_ast_to_pattern(ast) do - case ast do - atom when is_atom(atom) -> - atom - - number when is_number(number) -> - number - - string when is_binary(string) -> - string - - {var_name, _meta, nil} when is_atom(var_name) -> - :_ - - {:{}, _meta, elements} -> - elements - |> Enum.map(&elixir_ast_to_pattern/1) - |> List.to_tuple() + ann = :erl_anno.new(0) - {left, right} -> - {elixir_ast_to_pattern(left), elixir_ast_to_pattern(right)} + binding = :orddict.store(:VAR, List.to_tuple(args), []) - list when is_list(list) -> - Enum.map(list, &elixir_ast_to_pattern/1) + pattern_tuple = {:tuple, ann, clause.erl_args} - {:%{}, _meta, fields} -> - fields - |> Enum.map(fn {key, value} -> - {elixir_ast_to_pattern(key), elixir_ast_to_pattern(value)} - end) - |> Map.new() + {:value, _, binding} = + :erl_eval.expr({:match, ann, pattern_tuple, {:var, ann, :VAR}}, binding, :none) - {:%, _meta, [struct_name, {:%{}, _, fields}]} -> - field_map = - fields - |> Enum.map(fn {key, value} -> - {elixir_ast_to_pattern(key), elixir_ast_to_pattern(value)} - end) - |> Map.new() - - {:__struct__, struct_name, field_map} - - {:^, _meta, [var]} -> - elixir_ast_to_pattern(var) - - _ -> - :_ - end - end - - defp test_match(patterns, args) when length(patterns) != length(args), do: false - - defp test_match(patterns, args) do - Enum.zip(patterns, args) - |> Enum.all?(fn {pattern, arg} -> matches_value?(pattern, arg) end) - end - - defp matches_value?(:_, _), do: true - defp matches_value?(pattern, value) when pattern == value, do: true - - defp matches_value?({:__struct__, struct_name, field_map}, value) when is_struct(value) do - value.__struct__ == struct_name and - Enum.all?(field_map, fn {k, v} -> - matches_value?(v, Map.get(value, k)) - end) - end - - defp matches_value?(pattern_tuple, value_tuple) - when is_tuple(pattern_tuple) and is_tuple(value_tuple) do - if tuple_size(pattern_tuple) == tuple_size(value_tuple) do - pattern_list = Tuple.to_list(pattern_tuple) - value_list = Tuple.to_list(value_tuple) - test_match(pattern_list, value_list) - else - false + check_guards(clause.erl_guards, binding) + rescue + _ -> false + catch + _, _ -> false end end - defp matches_value?(pattern_map, value_map) when is_map(pattern_map) and is_map(value_map) do - Enum.all?(pattern_map, fn {k, v} -> - Map.has_key?(value_map, k) and matches_value?(v, Map.get(value_map, k)) + defp check_guards([], _binding), do: true + + defp check_guards(guards, binding) do + Enum.any?(guards, fn guard -> + try do + {:value, true, _} = :erl_eval.expr(guard, binding, :none) + true + rescue + _ -> false + catch + _, _ -> false + end end) end - defp matches_value?(pattern_list, value_list) - when is_list(pattern_list) and is_list(value_list) do - length(pattern_list) == length(value_list) and - test_match(pattern_list, value_list) - end - - defp matches_value?(_, _), do: false - # refactor defp get_module_file(module) do - case :code.which(module) do - :non_existing -> - {:error, :module_file_not_found} - - beam_file -> - source_file = get_source_file_from_beam(beam_file) - {:ok, source_file} - end - end - - defp get_source_file_from_beam(beam_file) do - try do - {:ok, {_module, [{:compile_info, compile_info}]}} = - :beam_lib.chunks(beam_file, [:compile_info]) - - case Keyword.get(compile_info, :source) do - # Fallback to beam file - nil -> List.to_string(beam_file) - source_path -> List.to_string(source_path) - end - rescue - # Fallback to beam file - _ -> List.to_string(beam_file) + case module.module_info(:compile)[:source] do + nil -> {:error, :module_file_not_found} + source -> {:ok, List.to_string(source)} end end end From 3ef8f21e29ec9d1fab25a883977b6dc2951db9ff Mon Sep 17 00:00:00 2001 From: srzeszut Date: Wed, 25 Mar 2026 18:23:37 +0100 Subject: [PATCH 03/15] add open editor button in traces --- .../callback_tracing/structs/trace_display.ex | 13 ++- .../callback_tracing/web/components/trace.ex | 79 +++++++++++++++++++ .../web/global_traces_live.ex | 13 ++- .../web/hook_components/trace_wrapper.ex | 58 ++++++++++++-- .../callback_tracing/web/node_traces_live.ex | 13 ++- .../app/debugger/utils/editor.ex | 35 ++++++++ .../web/live_components/node_basic_info.ex | 35 +------- .../gen_servers/trace_handler.ex | 2 - .../structs/trace/function_trace.ex | 16 +++- lib/live_debugger/utils/function_matcher.ex | 3 +- 10 files changed, 215 insertions(+), 52 deletions(-) diff --git a/lib/live_debugger/app/debugger/callback_tracing/structs/trace_display.ex b/lib/live_debugger/app/debugger/callback_tracing/structs/trace_display.ex index bac2d9b7d..28e8bac4f 100644 --- a/lib/live_debugger/app/debugger/callback_tracing/structs/trace_display.ex +++ b/lib/live_debugger/app/debugger/callback_tracing/structs/trace_display.ex @@ -25,7 +25,8 @@ defmodule LiveDebugger.App.Debugger.CallbackTracing.Structs.TraceDisplay do :body, :side_section_left, :side_section_right, - :error + :error, + :source ] @type type() :: :normal | :diff | :error @@ -44,7 +45,8 @@ defmodule LiveDebugger.App.Debugger.CallbackTracing.Structs.TraceDisplay do body: list({String.t(), term()}), side_section_left: side_section_left(), side_section_right: side_section_right(), - error: ErrorTrace.t() | nil + error: ErrorTrace.t() | nil, + source: SourceLocation.t() | nil } @spec from_trace(Trace.t(), boolean()) :: t() @@ -60,7 +62,8 @@ defmodule LiveDebugger.App.Debugger.CallbackTracing.Structs.TraceDisplay do body: get_body(trace), side_section_left: get_side_section_left(trace), side_section_right: get_side_section_right(trace), - error: get_error(trace) + error: get_error(trace), + source: get_source(trace) } end @@ -121,4 +124,8 @@ defmodule LiveDebugger.App.Debugger.CallbackTracing.Structs.TraceDisplay do defp get_error(%FunctionTrace{error: error}), do: error defp get_error(_), do: nil + + defp get_source(%FunctionTrace{source: source}), do: source + + defp get_source(_), do: nil end 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 d8b8a4072..4ba4253da 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 @@ -14,8 +14,63 @@ defmodule LiveDebugger.App.Debugger.CallbackTracing.Web.Components.Trace do alias LiveDebugger.Structs.Trace.ErrorTrace + alias LiveDebugger.App.Debugger.Utils.Editor alias Phoenix.LiveView.JS + @doc """ + Button to open the trace source in an external editor. + """ + attr(:id, :string, required: true) + attr(:elixir_editor, :string, default: nil) + attr(:source, :any, default: nil) + attr(:fullscreen?, :boolean, default: false) + + def open_in_editor_button(%{elixir_editor: nil} = assigns) do + assigns = assign(assigns, :editor_docs_url, Editor.editor_docs_url()) + + ~H""" + <.tooltip + :if={@source} + id={@id <> "-open-in-editor-tooltip"} + class="my-2" + content="Editor not configured. Click to see docs." + position="top-center" + fullscreen?={@fullscreen?} + > + + <.icon_button + id={"#{@id}-open-in-editor-button"} + icon="icon-external-link" + variant="secondary" + class="opacity-50" + /> + + + """ + end + + def open_in_editor_button(assigns) do + ~H""" + <.tooltip + :if={@source} + id={@id <> "-open-in-editor-tooltip"} + class="my-2" + content="Open in editor" + position="top-center" + fullscreen?={@fullscreen?} + > + <.icon_button + id={"#{@id}-open-in-editor-button"} + icon="icon-external-link" + phx-click="open-in-editor" + phx-value-file={@source.source_file} + phx-value-line={@source.line} + variant="secondary" + /> + + """ + end + @doc """ Displays the label of the trace with a polymorphic composition. """ @@ -106,6 +161,7 @@ defmodule LiveDebugger.App.Debugger.CallbackTracing.Web.Components.Trace do attr(:trace_display, TraceDisplay, required: true) attr(:search_phrase, :string, required: true) attr(:fullscreen?, :boolean, default: false) + attr(:elixir_editor, :string, default: nil) def trace_body_navbar_wrapper(assigns) do assigns = @@ -133,6 +189,7 @@ defmodule LiveDebugger.App.Debugger.CallbackTracing.Web.Components.Trace do "peer-checked/content:[&_.tab-content]:text-navbar-selected-bg peer-checked/content:[&_.tab-content]:border-navbar-selected-bg", "peer-checked/stack:[&_.tab-stack]:text-navbar-selected-bg peer-checked/stack:[&_.tab-stack]:border-navbar-selected-bg", "peer-checked/raw:[&_.tab-raw]:text-navbar-selected-bg peer-checked/raw:[&_.tab-raw]:border-navbar-selected-bg", + "peer-checked/content:[&_.editor-btn-content]:block", "peer-checked/stack:[&_.copy-btn-stack]:block", "peer-checked/raw:[&_.copy-btn-raw]:block" ]}> @@ -183,6 +240,15 @@ defmodule LiveDebugger.App.Debugger.CallbackTracing.Web.Components.Trace do /> + + <.fullscreen_button id={"trace-fullscreen-#{@id}"} class="m-2" @@ -237,6 +303,7 @@ defmodule LiveDebugger.App.Debugger.CallbackTracing.Web.Components.Trace do attr(:displayed_trace, TraceDisplay, required: true) attr(:search_phrase, :string, required: true) attr(:page, :atom, required: true, values: [:node_inspector, :global_callbacks]) + attr(:elixir_editor, :string, default: nil) def trace_fullscreen(assigns) do ~H""" @@ -261,6 +328,17 @@ defmodule LiveDebugger.App.Debugger.CallbackTracing.Web.Components.Trace do /> +
+ <.open_in_editor_button + id={@id <> "-fullscreen"} + elixir_editor={@elixir_editor} + source={@displayed_trace.source} + fullscreen?={true} + /> +
div>div>div>button]:hidden", if(is_nil(@displayed_trace.error), do: "p-4", else: "[&>div>div>div>div>button]:hidden") @@ -270,6 +348,7 @@ defmodule LiveDebugger.App.Debugger.CallbackTracing.Web.Components.Trace do trace_display={@displayed_trace} search_phrase={@search_phrase} fullscreen?={true} + elixir_editor={@elixir_editor} />
diff --git a/lib/live_debugger/app/debugger/callback_tracing/web/global_traces_live.ex b/lib/live_debugger/app/debugger/callback_tracing/web/global_traces_live.ex index 2acaeb346..3381a96f8 100644 --- a/lib/live_debugger/app/debugger/callback_tracing/web/global_traces_live.ex +++ b/lib/live_debugger/app/debugger/callback_tracing/web/global_traces_live.ex @@ -31,6 +31,7 @@ defmodule LiveDebugger.App.Debugger.CallbackTracing.Web.GlobalTracesLive do alias LiveDebugger.Services.ProcessMonitor.Events.LiveComponentCreated alias LiveDebugger.Services.ProcessMonitor.Events.LiveComponentDeleted alias LiveDebugger.App.Debugger.CallbackTracing.Web.LiveComponents.FiltersForm + alias LiveDebugger.App.Debugger.Utils.Editor @live_stream_limit 128 @page_size 25 @@ -97,7 +98,8 @@ defmodule LiveDebugger.App.Debugger.CallbackTracing.Web.GlobalTracesLive do node_id: nil, url: url, inspect_mode?: inspect_mode?, - return_link: return_link + return_link: return_link, + elixir_editor: Editor.detect_editor() ) |> stream(:existing_traces, [], reset: true) |> put_private(:page_size, @page_size) @@ -168,7 +170,11 @@ defmodule LiveDebugger.App.Debugger.CallbackTracing.Web.GlobalTracesLive do existing_traces={@streams.existing_traces} > <:trace :let={{id, trace_display}}> - + <:label> <.trace_label id={id <> "-label"} @@ -184,6 +190,7 @@ defmodule LiveDebugger.App.Debugger.CallbackTracing.Web.GlobalTracesLive do id={id <> "-body"} trace_display={trace_display} search_phrase={@trace_search_phrase} + elixir_editor={@elixir_editor} /> @@ -199,6 +206,7 @@ defmodule LiveDebugger.App.Debugger.CallbackTracing.Web.GlobalTracesLive do displayed_trace={@displayed_trace} search_phrase={@trace_search_phrase} page={:global_callbacks} + elixir_editor={@elixir_editor} /> @@ -262,4 +270,5 @@ defmodule LiveDebugger.App.Debugger.CallbackTracing.Web.GlobalTracesLive do |> assign(:inspect_mode?, !socket.assigns.inspect_mode?) |> noreply() end + end diff --git a/lib/live_debugger/app/debugger/callback_tracing/web/hook_components/trace_wrapper.ex b/lib/live_debugger/app/debugger/callback_tracing/web/hook_components/trace_wrapper.ex index f31dc5b02..0625638a2 100644 --- a/lib/live_debugger/app/debugger/callback_tracing/web/hook_components/trace_wrapper.ex +++ b/lib/live_debugger/app/debugger/callback_tracing/web/hook_components/trace_wrapper.ex @@ -20,11 +20,16 @@ defmodule LiveDebugger.App.Debugger.CallbackTracing.Web.HookComponents.TraceWrap use LiveDebugger.App.Web, :hook_component import LiveDebugger.App.Web.Hooks.Flash, only: [push_flash: 4] + import LiveDebugger.App.Debugger.CallbackTracing.Web.Components.Trace, only: [open_in_editor_button: 1] alias LiveDebugger.API.TracesStorage alias LiveDebugger.App.Debugger.CallbackTracing.Structs.TraceDisplay + alias LiveDebugger.App.Debugger.Utils.Editor + alias LiveDebugger.Utils.FunctionMatcher + alias LiveDebugger.Services.CallbackTracer.Actions.FunctionTrace + alias LiveDebugger.Structs.Trace.DiffTrace - @required_assigns [:lv_process, :displayed_trace, :parent_pid] + @required_assigns [:lv_process, :displayed_trace, :parent_pid, :elixir_editor] @trace_not_found_close_delay_ms 200 @impl true @@ -38,6 +43,7 @@ defmodule LiveDebugger.App.Debugger.CallbackTracing.Web.HookComponents.TraceWrap attr(:id, :string, required: true) attr(:trace_display, TraceDisplay, required: true) + attr(:elixir_editor, :string, required: true) slot(:body, required: true) slot(:label, required: true) @@ -66,12 +72,20 @@ defmodule LiveDebugger.App.Debugger.CallbackTracing.Web.HookComponents.TraceWrap :if={@trace_display.render_body? && is_nil(@trace_display.error)} class="absolute right-0 top-0 z-10" > - <.fullscreen_button - id={"trace-fullscreen-#{@id}"} - class="m-2" - phx-click="open-trace" - phx-value-trace-id={@trace_display.id} - /> +
+ <.open_in_editor_button + id={@id} + elixir_editor={@elixir_editor} + source={@trace_display.source} + /> + + <.fullscreen_button + id={"trace-fullscreen-#{@id}"} + class="m-2" + phx-click="open-trace" + phx-value-trace-id={@trace_display.id} + /> +
+ stream_insert_trace(socket, diff_trace, !render_body?) + trace -> + trace = maybe_resolve_source(trace) stream_insert_trace(socket, trace, !render_body?) end |> halt() end + defp handle_event("open-in-editor", %{"file" => file, "line" => line}, socket) do + Editor.open_in_editor( + socket.assigns.elixir_editor, + file, + String.to_integer(line), + socket.assigns.parent_pid + ) + + socket + |> halt() + end + defp handle_event(_, _, socket), do: {:cont, socket} defp handle_info({:trace_wrapper_not_found, string_trace_id}, socket) do @@ -136,6 +166,20 @@ defmodule LiveDebugger.App.Debugger.CallbackTracing.Web.HookComponents.TraceWrap defp handle_info(_, socket), do: {:cont, socket} + defp maybe_resolve_source(%{source: nil, module: module, function: function, args: args} = trace) do + case FunctionMatcher.find_matching_clause_line(module, function, args) do + {:ok, source} -> + new_trace = %{trace | source: source} + FunctionTrace.persist_trace(new_trace) + new_trace + + _ -> + trace + end + end + + defp maybe_resolve_source(trace), do: trace + defp get_trace(socket, string_trace_id) do TracesStorage.get_by_id!(socket.assigns.lv_process.pid, String.to_integer(string_trace_id)) end diff --git a/lib/live_debugger/app/debugger/callback_tracing/web/node_traces_live.ex b/lib/live_debugger/app/debugger/callback_tracing/web/node_traces_live.ex index e99337905..9b454f07b 100644 --- a/lib/live_debugger/app/debugger/callback_tracing/web/node_traces_live.ex +++ b/lib/live_debugger/app/debugger/callback_tracing/web/node_traces_live.ex @@ -12,6 +12,7 @@ defmodule LiveDebugger.App.Debugger.CallbackTracing.Web.NodeTracesLive do alias LiveDebugger.Structs.LvProcess + alias LiveDebugger.App.Debugger.Utils.Editor alias LiveDebugger.Bus alias LiveDebugger.App.Debugger.Events.NodeIdParamChanged @@ -74,7 +75,8 @@ defmodule LiveDebugger.App.Debugger.CallbackTracing.Web.NodeTracesLive do displayed_trace: nil, tracing_started?: false, trace_callback_running?: false, - trace_search_phrase: "" + trace_search_phrase: "", + elixir_editor: Editor.detect_editor() ) |> stream(:existing_traces, [], reset: true) |> put_private(:page_size, @page_size) @@ -138,7 +140,11 @@ defmodule LiveDebugger.App.Debugger.CallbackTracing.Web.NodeTracesLive do existing_traces={@streams.existing_traces} > <:trace :let={{id, trace_display}}> - + <:label> <.trace_label id={id <> "-label"} @@ -153,6 +159,7 @@ defmodule LiveDebugger.App.Debugger.CallbackTracing.Web.NodeTracesLive do id={id <> "-body"} trace_display={trace_display} search_phrase={@trace_search_phrase} + elixir_editor={@elixir_editor} /> @@ -172,6 +179,7 @@ defmodule LiveDebugger.App.Debugger.CallbackTracing.Web.NodeTracesLive do displayed_trace={@displayed_trace} search_phrase={@trace_search_phrase} page={:node_inspector} + elixir_editor={@elixir_editor} />
""" @@ -200,4 +208,5 @@ defmodule LiveDebugger.App.Debugger.CallbackTracing.Web.NodeTracesLive do end def handle_info(_, socket), do: {:noreply, socket} + end diff --git a/lib/live_debugger/app/debugger/utils/editor.ex b/lib/live_debugger/app/debugger/utils/editor.ex index 3802f85c2..aae60a641 100644 --- a/lib/live_debugger/app/debugger/utils/editor.ex +++ b/lib/live_debugger/app/debugger/utils/editor.ex @@ -39,6 +39,41 @@ defmodule LiveDebugger.App.Debugger.Utils.Editor do end end + @editor_docs_url "https://hexdocs.pm/live_debugger/open_in_editor.html" + + @doc """ + Returns the URL to the "Open in Editor" documentation. + """ + @spec editor_docs_url() :: String.t() + def editor_docs_url, do: @editor_docs_url + + @doc """ + Opens a file in the editor. Spawns a separate process to avoid blocking iex. + On error, sends a flash message to the given pid. + """ + @spec open_in_editor(String.t(), String.t(), integer(), pid()) :: :ok + def open_in_editor(editor, file, line, flash_pid) do + alias LiveDebugger.App.Web.Hooks.Flash.LinkFlashData + + cmd = get_editor_cmd(editor, file, line) + + spawn(fn -> + case run_shell_cmd(cmd) do + :ok -> + :ok + + {:error, reason} -> + send(flash_pid, {:put_flash, :error, %LinkFlashData{ + text: reason, + url: @editor_docs_url, + label: "See the docs" + }}) + end + end) + + :ok + end + @spec run_shell_cmd(String.t()) :: :ok | {:error, term()} def run_shell_cmd(command) do case System.shell(command, stderr_to_stdout: true) do diff --git a/lib/live_debugger/app/debugger/web/live_components/node_basic_info.ex b/lib/live_debugger/app/debugger/web/live_components/node_basic_info.ex index c2c834897..0685d8eac 100644 --- a/lib/live_debugger/app/debugger/web/live_components/node_basic_info.ex +++ b/lib/live_debugger/app/debugger/web/live_components/node_basic_info.ex @@ -5,35 +5,21 @@ defmodule LiveDebugger.App.Debugger.Web.LiveComponents.NodeBasicInfo do use LiveDebugger.App.Web, :live_component - import LiveDebugger.App.Web.Hooks.Flash, only: [push_flash: 3] alias LiveDebugger.App.Debugger.Structs.TreeNode alias LiveDebugger.App.Debugger.Queries.Node, as: NodeQueries alias LiveDebugger.App.Debugger.Web.LiveComponents.SendEventFullscreen alias LiveDebugger.App.Utils.Parsers alias LiveDebugger.App.Debugger.Web.Components.Pages alias LiveDebugger.App.Debugger.Utils.Editor - alias LiveDebugger.App.Web.Hooks.Flash.LinkFlashData - - @editor_docs_url "https://hexdocs.pm/live_debugger/open_in_editor.html" @impl true - def update(%{:editor_error => editor_error}, socket) do - socket - |> push_flash(:error, %LinkFlashData{ - text: editor_error, - url: @editor_docs_url, - label: "See the docs" - }) - |> ok() - end - def update(assigns, socket) do socket |> assign(:id, assigns.id) |> assign(:node_id, assigns.node_id) |> assign(:lv_process, assigns.lv_process) |> assign(:elixir_editor, Editor.detect_editor()) - |> assign(:editor_docs_url, @editor_docs_url) + |> assign(:editor_docs_url, Editor.editor_docs_url()) |> assign_node_type() |> assign_async_node_module() |> ok() @@ -170,24 +156,7 @@ defmodule LiveDebugger.App.Debugger.Web.LiveComponents.NodeBasicInfo do end def handle_event("open-in-editor", %{"file" => file, "line" => line}, socket) do - cmd = Editor.get_editor_cmd(socket.assigns.elixir_editor, file, line |> String.to_integer()) - - # Some editors may block iex, so we spawn a new process - component_id = socket.assigns.id - component_pid = self() - - spawn(fn -> - case Editor.run_shell_cmd(cmd) do - :ok -> - :ok - - {:error, reason} -> - send_update(component_pid, __MODULE__, - id: component_id, - editor_error: reason - ) - end - end) + Editor.open_in_editor(socket.assigns.elixir_editor, file, String.to_integer(line), self()) {:noreply, socket} end diff --git a/lib/live_debugger/services/callback_tracer/gen_servers/trace_handler.ex b/lib/live_debugger/services/callback_tracer/gen_servers/trace_handler.ex index fe1b22bcf..57297f19f 100644 --- a/lib/live_debugger/services/callback_tracer/gen_servers/trace_handler.ex +++ b/lib/live_debugger/services/callback_tracer/gen_servers/trace_handler.ex @@ -105,8 +105,6 @@ defmodule LiveDebugger.Services.CallbackTracer.GenServers.TraceHandler do with {:ok, trace} <- TraceActions.create_trace(n, module, fun, args, pid, ts), {:ok, ref} <- TraceActions.persist_trace(trace), :ok <- TraceActions.publish_trace(trace, ref) do - # dbg({module, fun, args}) - dbg({LiveDebugger.Utils.FunctionMatcher.find_matching_clause_line(module, fun, args)}) {:noreply, put_trace_record(state, trace, ref, ts)} else {:error, "Transport PID is nil"} -> diff --git a/lib/live_debugger/structs/trace/function_trace.ex b/lib/live_debugger/structs/trace/function_trace.ex index 37a24a45a..cfab0e485 100644 --- a/lib/live_debugger/structs/trace/function_trace.ex +++ b/lib/live_debugger/structs/trace/function_trace.ex @@ -13,6 +13,16 @@ defmodule LiveDebugger.Structs.Trace.FunctionTrace do alias LiveDebugger.Structs.Trace alias LiveDebugger.Structs.Trace.ErrorTrace + defmodule SourceLocation do + @moduledoc false + defstruct [:source_file, :line] + + @type t :: %__MODULE__{ + source_file: String.t(), + line: Integer.t() + } + end + defstruct [ :id, :pid, @@ -27,7 +37,8 @@ defmodule LiveDebugger.Structs.Trace.FunctionTrace do :execution_time, :type, :return_value, - :error + :error, + :source ] @type t() :: %__MODULE__{ @@ -45,7 +56,8 @@ defmodule LiveDebugger.Structs.Trace.FunctionTrace do execution_time: non_neg_integer() | nil, type: :call | :return_from | :exception_from, return_value: term() | nil, - error: ErrorTrace.t() | nil + error: ErrorTrace.t() | nil, + source: SourceLocation | nil } @doc """ diff --git a/lib/live_debugger/utils/function_matcher.ex b/lib/live_debugger/utils/function_matcher.ex index c99aaa67b..e28814fa1 100644 --- a/lib/live_debugger/utils/function_matcher.ex +++ b/lib/live_debugger/utils/function_matcher.ex @@ -2,12 +2,13 @@ defmodule LiveDebugger.Utils.FunctionMatcher do @moduledoc """ https://github.com/elixir-lang/elixir/blob/v1.20.0-rc.3/lib/elixir/lib/exception.ex#L245 """ + alias LiveDebugger.Structs.Trace.FunctionTrace.SourceLocation def find_matching_clause_line(module, function_name, args) do with {:ok, clauses} <- get_function_clauses(module, function_name, length(args)), {:ok, matching} <- find_matching(clauses, args), {:ok, file_path} <- get_module_file(module) do - {:ok, %{file: file_path, line: matching.line}} + {:ok, %SourceLocation{source_file: file_path, line: matching.line}} end end From 720ce6bdf67f729280d0f42b0e3e93ab473101db Mon Sep 17 00:00:00 2001 From: srzeszut Date: Wed, 25 Mar 2026 18:23:58 +0100 Subject: [PATCH 04/15] fix tooltip scroll --- assets/app/hooks/tooltip.js | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/assets/app/hooks/tooltip.js b/assets/app/hooks/tooltip.js index 7702994ff..3840b9d24 100644 --- a/assets/app/hooks/tooltip.js +++ b/assets/app/hooks/tooltip.js @@ -71,13 +71,19 @@ const Tooltip = { tooltipEl.style.display = 'none'; }; + this.handleScroll = () => { + tooltipEl.style.display = 'none'; + }; + this.el.addEventListener('mouseenter', this.handleMouseEnter); this.el.addEventListener('mouseleave', this.handleMouseLeave); + window.addEventListener('scroll', this.handleScroll, true); }, destroyed() { document.querySelector('#tooltip').style.display = 'none'; this.el.removeEventListener('mouseenter', this.handleMouseEnter); this.el.removeEventListener('mouseleave', this.handleMouseLeave); + window.removeEventListener('scroll', this.handleScroll, true); }, }; From f89ed0e2e77829bcf2bd0d77ea4b74e22ea19a44 Mon Sep 17 00:00:00 2001 From: srzeszut Date: Wed, 25 Mar 2026 18:37:34 +0100 Subject: [PATCH 05/15] add test --- test/app/debugger/utils/editor_test.exs | 19 ++++ test/utils/function_matcher_test.exs | 125 ++++++++++++++++++++++++ 2 files changed, 144 insertions(+) create mode 100644 test/utils/function_matcher_test.exs diff --git a/test/app/debugger/utils/editor_test.exs b/test/app/debugger/utils/editor_test.exs index b6405b956..c756c9aca 100644 --- a/test/app/debugger/utils/editor_test.exs +++ b/test/app/debugger/utils/editor_test.exs @@ -50,6 +50,25 @@ defmodule LiveDebugger.App.Debugger.Utils.EditorTest do end) end + test "sends flash error on command failure" do + alias LiveDebugger.App.Web.Hooks.Flash.LinkFlashData + + Editor.open_in_editor("1234", "lib/app.ex", 15, self()) + + assert_receive {:put_flash, :error, %LinkFlashData{text: text, url: url, label: label}}, + 5_000 + + assert text =~ "Error when opening editor" + assert url =~ "open_in_editor" + assert label == "See the docs" + end + + test "does not send flash on success" do + Editor.open_in_editor("echo", "lib/app.ex", 15, self()) + + refute_receive {:put_flash, _, _}, 1_000 + end + # Sets envs only for the duration of the test defp with_env(env_map, fun) do original_state = Map.new(env_map, fn {k, _} -> {k, System.get_env(k)} end) diff --git a/test/utils/function_matcher_test.exs b/test/utils/function_matcher_test.exs new file mode 100644 index 000000000..b4c2f6bd4 --- /dev/null +++ b/test/utils/function_matcher_test.exs @@ -0,0 +1,125 @@ +defmodule LiveDebugger.Utils.FunctionMatcherTest do + use ExUnit.Case, async: true + + alias LiveDebugger.Utils.FunctionMatcher + alias LiveDebugger.Structs.Trace.FunctionTrace.SourceLocation + + describe "find_matching_clause_line/3" do + test "returns source location for a simple function" do + args = [%{counter: 0}] + + assert {:ok, %SourceLocation{source_file: file, line: line}} = + FunctionMatcher.find_matching_clause_line( + LiveDebuggerDev.LiveViews.Main, + :render, + args + ) + + assert is_binary(file) + assert String.ends_with?(file, "dev/live_views/main.ex") + assert is_integer(line) + assert line > 0 + end + + test "matches the correct clause when guards are present" do + socket = %Phoenix.LiveView.Socket{} + + assert {:ok, %SourceLocation{line: guarded_line}} = + FunctionMatcher.find_matching_clause_line( + LiveDebuggerDev.LiveViews.Main, + :handle_event, + ["increment", %{}, socket] + ) + + assert {:ok, %SourceLocation{line: string_line}} = + FunctionMatcher.find_matching_clause_line( + LiveDebuggerDev.LiveViews.Main, + :handle_event, + ["increment", %{}, "a string socket"] + ) + + assert string_line < guarded_line + end + + test "matches different clauses based on pattern matching" do + socket = %Phoenix.LiveView.Socket{} + + assert {:ok, %SourceLocation{line: increment_line}} = + FunctionMatcher.find_matching_clause_line( + LiveDebuggerDev.LiveViews.Main, + :handle_event, + ["increment", %{}, socket] + ) + + assert {:ok, %SourceLocation{line: slow_line}} = + FunctionMatcher.find_matching_clause_line( + LiveDebuggerDev.LiveViews.Main, + :handle_event, + ["slow-increment", %{}, socket] + ) + + assert increment_line != slow_line + end + + test "returns the same file path for all clauses of the same module" do + socket = %Phoenix.LiveView.Socket{} + + assert {:ok, %SourceLocation{source_file: file1}} = + FunctionMatcher.find_matching_clause_line( + LiveDebuggerDev.LiveViews.Main, + :render, + [%{counter: 0}] + ) + + assert {:ok, %SourceLocation{source_file: file2}} = + FunctionMatcher.find_matching_clause_line( + LiveDebuggerDev.LiveViews.Main, + :handle_event, + ["increment", %{}, socket] + ) + + assert file1 == file2 + end + + test "returns error for non-existent module" do + assert {:error, _} = + FunctionMatcher.find_matching_clause_line( + NonExistentModule, + :foo, + [] + ) + end + + test "returns error for non-existent function" do + assert {:error, _} = + FunctionMatcher.find_matching_clause_line( + LiveDebuggerDev.LiveViews.Main, + :non_existent_function, + [] + ) + end + + test "returns error when no clause matches the given args" do + assert {:error, :no_matching_clause} = + FunctionMatcher.find_matching_clause_line( + LiveDebuggerDev.LiveViews.Main, + :handle_info, + [:unmatched_message, %Phoenix.LiveView.Socket{}] + ) + end + + test "works with LiveComponent modules" do + assigns = %{id: "test", myself: %Phoenix.LiveComponent.CID{cid: 1}} + + assert {:ok, %SourceLocation{source_file: file, line: line}} = + FunctionMatcher.find_matching_clause_line( + LiveDebuggerDev.LiveComponents.Name, + :update, + [assigns, %Phoenix.LiveView.Socket{}] + ) + + assert String.ends_with?(file, "dev/live_components/name.ex") + assert line > 0 + end + end +end From 09d6490bab1ba5f5c45afbda3587026a69852494 Mon Sep 17 00:00:00 2001 From: srzeszut Date: Thu, 26 Mar 2026 10:05:58 +0100 Subject: [PATCH 06/15] add moduledoc --- lib/live_debugger/utils/function_matcher.ex | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/lib/live_debugger/utils/function_matcher.ex b/lib/live_debugger/utils/function_matcher.ex index e28814fa1..73a1d8eb5 100644 --- a/lib/live_debugger/utils/function_matcher.ex +++ b/lib/live_debugger/utils/function_matcher.ex @@ -1,5 +1,10 @@ defmodule LiveDebugger.Utils.FunctionMatcher do @moduledoc """ + Matches function calls to their defining clause by replaying pattern matching + against BEAM debug info. Given a module, function name, and actual arguments, + it identifies which clause would handle the call and returns its source location. + + Inspired by Elixir's exception logic: https://github.com/elixir-lang/elixir/blob/v1.20.0-rc.3/lib/elixir/lib/exception.ex#L245 """ alias LiveDebugger.Structs.Trace.FunctionTrace.SourceLocation From 64a20c01a58b57d7d2c09d5dd55ba8eb8ff2514f Mon Sep 17 00:00:00 2001 From: srzeszut Date: Thu, 26 Mar 2026 10:17:02 +0100 Subject: [PATCH 07/15] mix format --- .../callback_tracing/web/global_traces_live.ex | 1 - .../web/hook_components/trace_wrapper.ex | 8 ++++++-- .../callback_tracing/web/node_traces_live.ex | 1 - lib/live_debugger/app/debugger/utils/editor.ex | 14 +++++++++----- 4 files changed, 15 insertions(+), 9 deletions(-) diff --git a/lib/live_debugger/app/debugger/callback_tracing/web/global_traces_live.ex b/lib/live_debugger/app/debugger/callback_tracing/web/global_traces_live.ex index 3381a96f8..945389a7f 100644 --- a/lib/live_debugger/app/debugger/callback_tracing/web/global_traces_live.ex +++ b/lib/live_debugger/app/debugger/callback_tracing/web/global_traces_live.ex @@ -270,5 +270,4 @@ defmodule LiveDebugger.App.Debugger.CallbackTracing.Web.GlobalTracesLive do |> assign(:inspect_mode?, !socket.assigns.inspect_mode?) |> noreply() end - end diff --git a/lib/live_debugger/app/debugger/callback_tracing/web/hook_components/trace_wrapper.ex b/lib/live_debugger/app/debugger/callback_tracing/web/hook_components/trace_wrapper.ex index 0625638a2..af944b341 100644 --- a/lib/live_debugger/app/debugger/callback_tracing/web/hook_components/trace_wrapper.ex +++ b/lib/live_debugger/app/debugger/callback_tracing/web/hook_components/trace_wrapper.ex @@ -20,7 +20,9 @@ defmodule LiveDebugger.App.Debugger.CallbackTracing.Web.HookComponents.TraceWrap use LiveDebugger.App.Web, :hook_component import LiveDebugger.App.Web.Hooks.Flash, only: [push_flash: 4] - import LiveDebugger.App.Debugger.CallbackTracing.Web.Components.Trace, only: [open_in_editor_button: 1] + + import LiveDebugger.App.Debugger.CallbackTracing.Web.Components.Trace, + only: [open_in_editor_button: 1] alias LiveDebugger.API.TracesStorage alias LiveDebugger.App.Debugger.CallbackTracing.Structs.TraceDisplay @@ -166,7 +168,9 @@ defmodule LiveDebugger.App.Debugger.CallbackTracing.Web.HookComponents.TraceWrap defp handle_info(_, socket), do: {:cont, socket} - defp maybe_resolve_source(%{source: nil, module: module, function: function, args: args} = trace) do + defp maybe_resolve_source( + %{source: nil, module: module, function: function, args: args} = trace + ) do case FunctionMatcher.find_matching_clause_line(module, function, args) do {:ok, source} -> new_trace = %{trace | source: source} diff --git a/lib/live_debugger/app/debugger/callback_tracing/web/node_traces_live.ex b/lib/live_debugger/app/debugger/callback_tracing/web/node_traces_live.ex index 9b454f07b..f8612e058 100644 --- a/lib/live_debugger/app/debugger/callback_tracing/web/node_traces_live.ex +++ b/lib/live_debugger/app/debugger/callback_tracing/web/node_traces_live.ex @@ -208,5 +208,4 @@ defmodule LiveDebugger.App.Debugger.CallbackTracing.Web.NodeTracesLive do end def handle_info(_, socket), do: {:noreply, socket} - end diff --git a/lib/live_debugger/app/debugger/utils/editor.ex b/lib/live_debugger/app/debugger/utils/editor.ex index aae60a641..36b1a2dd3 100644 --- a/lib/live_debugger/app/debugger/utils/editor.ex +++ b/lib/live_debugger/app/debugger/utils/editor.ex @@ -63,11 +63,15 @@ defmodule LiveDebugger.App.Debugger.Utils.Editor do :ok {:error, reason} -> - send(flash_pid, {:put_flash, :error, %LinkFlashData{ - text: reason, - url: @editor_docs_url, - label: "See the docs" - }}) + send( + flash_pid, + {:put_flash, :error, + %LinkFlashData{ + text: reason, + url: @editor_docs_url, + label: "See the docs" + }} + ) end end) From 26e20c0a1ec84c0fbed247441cbca0082b6f4ad8 Mon Sep 17 00:00:00 2001 From: srzeszut Date: Fri, 27 Mar 2026 12:19:39 +0100 Subject: [PATCH 08/15] small refactor --- .../callback_tracing/structs/trace_display.ex | 2 +- .../callback_tracing/web/components/trace.ex | 17 +++++++++-------- .../web/hook_components/trace_wrapper.ex | 8 ++++---- .../structs/trace/function_trace.ex | 4 ++-- 4 files changed, 16 insertions(+), 15 deletions(-) diff --git a/lib/live_debugger/app/debugger/callback_tracing/structs/trace_display.ex b/lib/live_debugger/app/debugger/callback_tracing/structs/trace_display.ex index 28e8bac4f..f8c22b45c 100644 --- a/lib/live_debugger/app/debugger/callback_tracing/structs/trace_display.ex +++ b/lib/live_debugger/app/debugger/callback_tracing/structs/trace_display.ex @@ -46,7 +46,7 @@ defmodule LiveDebugger.App.Debugger.CallbackTracing.Structs.TraceDisplay do side_section_left: side_section_left(), side_section_right: side_section_right(), error: ErrorTrace.t() | nil, - source: SourceLocation.t() | nil + source: FunctionTrace.SourceLocation.t() | nil } @spec from_trace(Trace.t(), boolean()) :: t() 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 4ba4253da..d9d7b89fc 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 @@ -37,14 +37,15 @@ defmodule LiveDebugger.App.Debugger.CallbackTracing.Web.Components.Trace do position="top-center" fullscreen?={@fullscreen?} > - - <.icon_button - id={"#{@id}-open-in-editor-button"} - icon="icon-external-link" - variant="secondary" - class="opacity-50" - /> - + <.button_link + href={@editor_docs_url} + id={"#{@id}-open-in-editor"} + variant="secondary" + size="sm" + class="opacity-50 cursor-pointer" + > + <.icon name="icon-external-link" class="w-4 h-4" /> + """ end diff --git a/lib/live_debugger/app/debugger/callback_tracing/web/hook_components/trace_wrapper.ex b/lib/live_debugger/app/debugger/callback_tracing/web/hook_components/trace_wrapper.ex index af944b341..fa0be24ec 100644 --- a/lib/live_debugger/app/debugger/callback_tracing/web/hook_components/trace_wrapper.ex +++ b/lib/live_debugger/app/debugger/callback_tracing/web/hook_components/trace_wrapper.ex @@ -45,7 +45,7 @@ defmodule LiveDebugger.App.Debugger.CallbackTracing.Web.HookComponents.TraceWrap attr(:id, :string, required: true) attr(:trace_display, TraceDisplay, required: true) - attr(:elixir_editor, :string, required: true) + attr(:elixir_editor, :string, default: nil) slot(:body, required: true) slot(:label, required: true) @@ -139,7 +139,7 @@ defmodule LiveDebugger.App.Debugger.CallbackTracing.Web.HookComponents.TraceWrap stream_insert_trace(socket, diff_trace, !render_body?) trace -> - trace = maybe_resolve_source(trace) + trace = maybe_resolve_and_persist_source(trace) stream_insert_trace(socket, trace, !render_body?) end |> halt() @@ -168,7 +168,7 @@ defmodule LiveDebugger.App.Debugger.CallbackTracing.Web.HookComponents.TraceWrap defp handle_info(_, socket), do: {:cont, socket} - defp maybe_resolve_source( + defp maybe_resolve_and_persist_source( %{source: nil, module: module, function: function, args: args} = trace ) do case FunctionMatcher.find_matching_clause_line(module, function, args) do @@ -182,7 +182,7 @@ defmodule LiveDebugger.App.Debugger.CallbackTracing.Web.HookComponents.TraceWrap end end - defp maybe_resolve_source(trace), do: trace + defp maybe_resolve_and_persist_source(trace), do: trace defp get_trace(socket, string_trace_id) do TracesStorage.get_by_id!(socket.assigns.lv_process.pid, String.to_integer(string_trace_id)) diff --git a/lib/live_debugger/structs/trace/function_trace.ex b/lib/live_debugger/structs/trace/function_trace.ex index cfab0e485..25b251805 100644 --- a/lib/live_debugger/structs/trace/function_trace.ex +++ b/lib/live_debugger/structs/trace/function_trace.ex @@ -19,7 +19,7 @@ defmodule LiveDebugger.Structs.Trace.FunctionTrace do @type t :: %__MODULE__{ source_file: String.t(), - line: Integer.t() + line: non_neg_integer() } end @@ -57,7 +57,7 @@ defmodule LiveDebugger.Structs.Trace.FunctionTrace do type: :call | :return_from | :exception_from, return_value: term() | nil, error: ErrorTrace.t() | nil, - source: SourceLocation | nil + source: SourceLocation.t() | nil } @doc """ From c420647cc273a5ab21090e43549297313d54a2e4 Mon Sep 17 00:00:00 2001 From: srzeszut Date: Fri, 27 Mar 2026 16:24:33 +0100 Subject: [PATCH 09/15] fix button in fullscreen --- .../callback_tracing/web/components/trace.ex | 47 ++++++++++--------- lib/live_debugger/app/web/components.ex | 2 +- 2 files changed, 26 insertions(+), 23 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 d9d7b89fc..3a8f42ba4 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 @@ -329,28 +329,31 @@ defmodule LiveDebugger.App.Debugger.CallbackTracing.Web.Components.Trace do /> -
- <.open_in_editor_button - id={@id <> "-fullscreen"} - elixir_editor={@elixir_editor} - source={@displayed_trace.source} - fullscreen?={true} - /> -
-
div>div>div>button]:hidden", - if(is_nil(@displayed_trace.error), do: "p-4", else: "[&>div>div>div>div>button]:hidden") - ]}> - <.trace_body_navbar_wrapper - id={@id <> "-fullscreen"} - trace_display={@displayed_trace} - search_phrase={@search_phrase} - fullscreen?={true} - elixir_editor={@elixir_editor} - /> + +
+
+ <.open_in_editor_button + id={@id <> "-fullscreen"} + elixir_editor={@elixir_editor} + source={@displayed_trace.source} + fullscreen?={true} + /> +
+
div>div>div>button]:hidden", + if(is_nil(@displayed_trace.error), do: "p-4", else: "[&>div>div>div>div>button]:hidden") + ]}> + <.trace_body_navbar_wrapper + id={@id <> "-fullscreen"} + trace_display={@displayed_trace} + search_phrase={@search_phrase} + fullscreen?={true} + elixir_editor={@elixir_editor} + /> +
""" diff --git a/lib/live_debugger/app/web/components.ex b/lib/live_debugger/app/web/components.ex index fa416ca64..fd698e951 100644 --- a/lib/live_debugger/app/web/components.ex +++ b/lib/live_debugger/app/web/components.ex @@ -756,7 +756,7 @@ defmodule LiveDebugger.App.Web.Components do phx-hook="Fullscreen" data-send-close-event={@send_close_event} class={[ - "relative h-max w-full xl:w-max xl:min-w-[50rem] bg-surface-0-bg overflow-auto hidden flex-col rounded-md backdrop:bg-black backdrop:opacity-50" + "relative h-max w-full xl:w-max xl:min-w-[50rem] bg-surface-0-bg overflow-hidden hidden flex-col rounded-md backdrop:bg-black backdrop:opacity-50" | List.wrap(@class) ]} > From bf30eb5411803b6501dcda5c801fac14d8445c59 Mon Sep 17 00:00:00 2001 From: srzeszut Date: Fri, 27 Mar 2026 16:34:45 +0100 Subject: [PATCH 10/15] fix overflow --- lib/live_debugger/app/web/components.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/live_debugger/app/web/components.ex b/lib/live_debugger/app/web/components.ex index fd698e951..fa416ca64 100644 --- a/lib/live_debugger/app/web/components.ex +++ b/lib/live_debugger/app/web/components.ex @@ -756,7 +756,7 @@ defmodule LiveDebugger.App.Web.Components do phx-hook="Fullscreen" data-send-close-event={@send_close_event} class={[ - "relative h-max w-full xl:w-max xl:min-w-[50rem] bg-surface-0-bg overflow-hidden hidden flex-col rounded-md backdrop:bg-black backdrop:opacity-50" + "relative h-max w-full xl:w-max xl:min-w-[50rem] bg-surface-0-bg overflow-auto hidden flex-col rounded-md backdrop:bg-black backdrop:opacity-50" | List.wrap(@class) ]} > From b06d7b9c074348ff51ee0e08a99f0ff66c79748e Mon Sep 17 00:00:00 2001 From: srzeszut Date: Mon, 30 Mar 2026 12:56:36 +0200 Subject: [PATCH 11/15] fix credo --- lib/live_debugger/utils/function_matcher.ex | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/lib/live_debugger/utils/function_matcher.ex b/lib/live_debugger/utils/function_matcher.ex index 73a1d8eb5..9c25459c7 100644 --- a/lib/live_debugger/utils/function_matcher.ex +++ b/lib/live_debugger/utils/function_matcher.ex @@ -36,11 +36,7 @@ defmodule LiveDebugger.Utils.FunctionMatcher do scope ) - erl_guards = - Enum.map(guards, fn guard -> - {erl_guard, _scope} = :elixir_erl_pass.translate(guard, ann, scope) - erl_guard - end) + erl_guards = translate_guards(guards, ann, scope) %{ line: Keyword.get(meta, :line, 0), @@ -98,6 +94,13 @@ defmodule LiveDebugger.Utils.FunctionMatcher do end) end + defp translate_guards(guards, ann, scope) do + Enum.map(guards, fn guard -> + {erl_guard, _scope} = :elixir_erl_pass.translate(guard, ann, scope) + erl_guard + end) + end + defp get_module_file(module) do case module.module_info(:compile)[:source] do nil -> {:error, :module_file_not_found} From f4909e03fa442eb3925e61dc52dddf413185bdd8 Mon Sep 17 00:00:00 2001 From: srzeszut Date: Mon, 30 Mar 2026 15:00:30 +0200 Subject: [PATCH 12/15] fix e2e test --- e2e/tests/async-jobs.spec.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/e2e/tests/async-jobs.spec.ts b/e2e/tests/async-jobs.spec.ts index 78b62d30a..0111c5b0a 100644 --- a/e2e/tests/async-jobs.spec.ts +++ b/e2e/tests/async-jobs.spec.ts @@ -62,6 +62,7 @@ test('user can see and track async jobs in LiveView and LiveComponent', async ({ 'No active async jobs found' ); + await devApp.locator('#component-long-load-toggle').click(); await devApp.locator('#component-start-cancelable-async-button').click(); await expect( asyncJobName(dbgApp, ':component_cancelable_fetch') From d74366245264e41a9d099f6ead3370b43e7e91bb Mon Sep 17 00:00:00 2001 From: srzeszut Date: Tue, 31 Mar 2026 13:02:37 +0200 Subject: [PATCH 13/15] fix e2e test --- e2e/tests/elements-inspection.spec.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/e2e/tests/elements-inspection.spec.ts b/e2e/tests/elements-inspection.spec.ts index fa2184e13..15c558b53 100644 --- a/e2e/tests/elements-inspection.spec.ts +++ b/e2e/tests/elements-inspection.spec.ts @@ -1,6 +1,7 @@ import { expect, findNodeModuleInfo, + findSidebarBasicInfo, prepareDevDebuggerPairTest, getDevPid, Page, @@ -42,7 +43,7 @@ const selectLiveViewByPid = async (dbgApp: Page, pid: string) => { ); await btn.hover(); await btn.click(); - await expect(findNodeModuleInfo(dbgApp)).toBeVisible(); + await expect(findSidebarBasicInfo(dbgApp)).toBeVisible(); }; const openDbgForLiveView = async ( @@ -67,7 +68,7 @@ const openMobileDbgForLiveView = async ( ); await btn.hover(); await btn.click(); - await expect(findNodeModuleInfo(dbgApp)).toBeVisible(); + await expect(findSidebarBasicInfo(dbgApp)).toBeVisible(); return dbgApp; }; From 6260a026aa491705d38fd572fe8270c2ede361a6 Mon Sep 17 00:00:00 2001 From: srzeszut Date: Wed, 1 Apr 2026 23:27:28 +0200 Subject: [PATCH 14/15] CR suggestions --- .../callback_tracing/web/components/trace.ex | 40 +++++++------------ lib/live_debugger/utils/function_matcher.ex | 2 + test/utils/function_matcher_test.exs | 2 +- 3 files changed, 18 insertions(+), 26 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 3a8f42ba4..cd0181d36 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 @@ -22,10 +22,10 @@ defmodule LiveDebugger.App.Debugger.CallbackTracing.Web.Components.Trace do """ attr(:id, :string, required: true) attr(:elixir_editor, :string, default: nil) - attr(:source, :any, default: nil) + attr(:source, SourceLocation, default: nil) attr(:fullscreen?, :boolean, default: false) - def open_in_editor_button(%{elixir_editor: nil} = assigns) do + def open_in_editor_button(assigns) do assigns = assign(assigns, :editor_docs_url, Editor.editor_docs_url()) ~H""" @@ -33,11 +33,23 @@ defmodule LiveDebugger.App.Debugger.CallbackTracing.Web.Components.Trace do :if={@source} id={@id <> "-open-in-editor-tooltip"} class="my-2" - content="Editor not configured. Click to see docs." + content={ + if(@elixir_editor, do: "Open in editor", else: "Editor not configured. Click to see docs.") + } position="top-center" fullscreen?={@fullscreen?} > + <.icon_button + :if={@elixir_editor} + id={"#{@id}-open-in-editor-button"} + icon="icon-external-link" + phx-click="open-in-editor" + phx-value-file={@source.source_file} + phx-value-line={@source.line} + variant="secondary" + /> <.button_link + :if={@elixir_editor == nil} href={@editor_docs_url} id={"#{@id}-open-in-editor"} variant="secondary" @@ -50,28 +62,6 @@ defmodule LiveDebugger.App.Debugger.CallbackTracing.Web.Components.Trace do """ end - def open_in_editor_button(assigns) do - ~H""" - <.tooltip - :if={@source} - id={@id <> "-open-in-editor-tooltip"} - class="my-2" - content="Open in editor" - position="top-center" - fullscreen?={@fullscreen?} - > - <.icon_button - id={"#{@id}-open-in-editor-button"} - icon="icon-external-link" - phx-click="open-in-editor" - phx-value-file={@source.source_file} - phx-value-line={@source.line} - variant="secondary" - /> - - """ - end - @doc """ Displays the label of the trace with a polymorphic composition. """ diff --git a/lib/live_debugger/utils/function_matcher.ex b/lib/live_debugger/utils/function_matcher.ex index 9c25459c7..7da11811e 100644 --- a/lib/live_debugger/utils/function_matcher.ex +++ b/lib/live_debugger/utils/function_matcher.ex @@ -15,6 +15,8 @@ defmodule LiveDebugger.Utils.FunctionMatcher do {:ok, file_path} <- get_module_file(module) do {:ok, %SourceLocation{source_file: file_path, line: matching.line}} end + rescue + _ -> :error end defp get_function_clauses(module, function_name, arity) do diff --git a/test/utils/function_matcher_test.exs b/test/utils/function_matcher_test.exs index b4c2f6bd4..91413a4da 100644 --- a/test/utils/function_matcher_test.exs +++ b/test/utils/function_matcher_test.exs @@ -18,7 +18,7 @@ defmodule LiveDebugger.Utils.FunctionMatcherTest do assert is_binary(file) assert String.ends_with?(file, "dev/live_views/main.ex") assert is_integer(line) - assert line > 0 + assert line == 40 end test "matches the correct clause when guards are present" do From 13592601ec1f6860d5fae05a2749f3451b9342cd Mon Sep 17 00:00:00 2001 From: srzeszut Date: Wed, 1 Apr 2026 23:29:31 +0200 Subject: [PATCH 15/15] fix tooltip message --- .../app/debugger/callback_tracing/web/components/trace.ex | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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 cd0181d36..f266e8e4a 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 @@ -34,7 +34,10 @@ defmodule LiveDebugger.App.Debugger.CallbackTracing.Web.Components.Trace do id={@id <> "-open-in-editor-tooltip"} class="my-2" content={ - if(@elixir_editor, do: "Open in editor", else: "Editor not configured. Click to see docs.") + if(@elixir_editor, + do: "Open in editor", + else: "Editor not configured. Click for setup instructions." + ) } position="top-center" fullscreen?={@fullscreen?}