Made a Source Code Inspector, useful in big projects or large teams

I didn’t think I could do it, but after getting the hang of the Hooks, I have created a tool that I really needed in front-end web development space.

I can barely remember what I worked on today, let alone the file I worked on. So when I get thrown into a project or get asked to work on a web component that was built by my colleagues, I get lost in the labyrinthian code.

Here’s my best attempt at solving that issue:

Source Code Viewer


The tooltip has 2 icons, 1 that leads to the source code, and the other will lead to the Storybook components page. Which I haven’t implemented yet.

Also, this is currently restricted to dev, but I wish to enable it for prod as well. We can enable the inspector like we enable latency sim, and clicking on the show source would open up GitHub/Storybook.

And it works everywhere, see:

image


P.S. Just stuck figuring out a way to add multiple JS Hook. (I found out a post on Elixir Forum, by someone who figured it out and even did lazy loaded Hook!)

P.P.S. It’s not a library, just changes made across several places. I can paste the whole thing if people want. Took me a while to get the popup to work, but it was worth it.


Inspired by: https://bit.dev (Toggle the inspect button in the top right to hover over each element and see their names and link!)

I suggested this to many people, I really want this to be part of LiveView, or Storybook, or both. (See: Add dev config for injecting HTML comments around function components)


P.S. Here’s the full source code:

34 Likes

This is fantastic. I really really like this.

I do wonder if there is a way to sort of “layer” this on top of LiveView instead of requiring each component to pass this stuff in.

2 Likes

Exactly my thought.

That’s why I suggested the same in this Git issue: Add dev config for injecting HTML comments around function components

1 Like

Here’s the code, so others can play around with it, till the Phoenix team adds it to Phoenix 2.0!

defmodule DerpyToolsWeb.Nav do
import Phoenix.LiveView
use Phoenix.Component

def on_mount(:default, _params, _session, socket) do
  {:cont,
   socket
   |> attach_hook(:inspect_source, :handle_event, &handle_event/3)}
end

defp handle_event("inspect-source", %{"file" => file, "line" => line}, socket) do
  System.cmd("code", ["--goto", "#{file}:#{line}"])

  {:halt, socket}
end

defp handle_event(_, _, socket), do: {:cont, socket}
scope "/", DerpyToolsWeb do
  pipe_through :browser

  live_session :no_log_in_required,
    on_mount: [DerpyToolsWeb.Nav] do
    live "/", HomePageLive
    ...
  end
end

Usage

<h2
  id="test-div"
  data-file={__ENV__.file}
  data-line={__ENV__.line}
  phx-hook={Mix.env() == :dev && "SourceInspector"}
  class="..."
>
    Hover over this!
</h2>

JavaScript Side

source_inspector.js

import { computePosition, flip, offset, arrow } from "../vendor/floating-ui";

const SourceInspector = {
  mounted() {
    if (!this.el.dataset) {
      console.log("Please pass in file & line data attributes!");
      return;
    }

    const globalTooltip = document.querySelector("#inspector-tooltip");

    let tooltip = globalTooltip.cloneNode(true);
    tooltip.setAttribute("id", `inspect-${this.el.id}`);
    const inspectSourceBtn = tooltip.querySelector("#source-btn");
    const arrowElement = tooltip.querySelector("#arrow");

    this.el.addEventListener("mouseenter", () => {
      const { file, line } = this.el.dataset;

      this.el.appendChild(tooltip);

      tooltip.classList.remove("hidden");
      tooltip.classList.add("flex");

      placeTooltip(this.el, tooltip, arrowElement);

      this.el.classList.add(
        "rounded-lg",
        "outline",
        "outline-offset-4",
        "outline-pink-500"
      );

      inspectSourceBtn.setAttribute("phx-value-file", file);
      inspectSourceBtn.setAttribute("phx-value-line", line);
    });
    this.el.addEventListener("mouseleave", (e) => {
      handleMouseLeave(this.el, tooltip);
    });
  },
};

function handleMouseLeave(target, tooltip) {
  target.classList.remove(
    "rounded-lg",
    "outline",
    "outline-offset-4",
    "outline-pink-500"
  );

  tooltip.classList.add("hidden");
  tooltip.classList.remove("flex");
}

function placeTooltip(target, tooltip, arrowElement) {
  computePosition(target, tooltip, {
    placement: "top",
    middleware: [
      flip(),
      offset(8),
      arrow({
        element: arrowElement,
      }),
    ],
  }).then(({ x, y, placement, middlewareData }) => {
    Object.assign(tooltip.style, {
      left: `${x}px`,
      top: `${y}px`,
    });

    const { x: arrowX, y: arrowY } = middlewareData.arrow;

    const staticSide = {
      top: "bottom",
      right: "left",
      bottom: "top",
      left: "right",
    }[placement.split("-")[0]];

    Object.assign(arrowElement.style, {
      left: arrowX != null ? `${arrowX}px` : "",
      top: arrowY != null ? `${arrowY}px` : "",
      right: "",
      bottom: "",
      [staticSide]: "-10px",
    });
  });
}

export default SourceInspector;

app.js

import SourceInspector from "./source_inspector";

let liveSocket = new LiveSocket("/live", Socket, {
  params: {
    _csrf_token: csrfToken,
  },
  hooks: {
    SourceInspector,
  },
});

N.B. Don’t forget to add this in the environment variable:
export ELIXIR_EDITOR="code --goto __FILE__:__LINE__"

This way, the Beam instance will know to open the VS Code editor!


P.S. I used float-ui, which is the next iteration of Popper.js, for the tooltip.

Just download the ESM files from JS Delivr, i.e.

5 Likes

Love the approach! I think that you are into something great, as I think that we failed as developers to find a human-compatible way to build complex systems.

I always loved things like scratch, they present building blocks that are very similar to the way we write code, capable of building complex tings, and it can be operated by a 8 year old kid.

2 Likes

That, plus I have a major qualm with the current state of Web Development:

  1. There’s a lack of a tool, as universal and unchanging as a Guitar. (Why does Node.js exist? It’s just a time-sink to debug. :bug:)
  2. The wrapper-around-wrapper approach that’s nuking performance back to the 1980s. (Microsoft Teams and it’s slow as heck next version is being shoved down our throat as fast! :turtle:)
  3. Code rot, in projects built with nearsighted frameworks. (My angular project, which I built 5 years back, doesn’t run anymore. :x:)
  4. Complexity that has crept into the tooling. (Webpack at work, compiles our project which is written in an Interpreted language, at a snail’s pace. :snail:)

I wanted to quit this field. But decided to stick around because of Elixir, Phoenix & Live View.

Hope it pans out.

5 Likes

This makes me think of a feature that is also in the Smalltalk webframework seaside.st. There it’s called a halo decoration there. You can see it in action here: Fun with Seaside XHTML Canvas

I can remember it was a really nice workflow to be able to toggle between a render mode and source mode. I think one of the buttons also brought you straight to the implementation of the component (in this case, inside the smalltalk image, which is another beast entirely, but it could also work to bring you to the correct file inside your IDE).
In any case, it also made clear how your components were nested, and what the structure was of your entire page.

2 Likes

This is something that is present in a lot of google products, one that I closely hate is the native android development.

Sometimes I have a feeling that all the new features and ways to write code are designed in an agile system, kind of a innovation with timelines, witch is not only stupid sounding, but impossible to achieve.

2 Likes

That’s awesome.

Being able to toggle the editor view and the rendered view. (Kind of like LiveBook 2.0.)

So, we see the components we build, then inline make the change to the code, and then render and the source code is replaced with the rendered view.

Then all the changes we make in the browser-based IDE are written back to the file. So we won’t even have to jump back to the VS Code or any other external IDE.

Just REPL it directly on the browser.

1 Like

It’s closely related to what’s called REPL-driven-development. I don’t know if Elixir is capable of what’s being described in that article (e.g. with the breakloop) and can stand next to Smalltalk and lisp/Clojure in that regard.

1 Like

Actually, Jose Valim intends to build real-time code, REPL-based development. See:

3 Likes

There’s also GitHub - kagux/ex_debug_toolbar: A debug web toolbar for Phoenix projects to display all sorts of information about request which is likely no longer active, GitHub - mcrumm/phoenix_profiler: Web Profiler and Debug Toolbar for Phoenix Framework, and GitHub - caleb-bb/periscope: Tools for analyzing Phoenix Liveview applications which were both updated this year. I mention them all for inspiration as there may be approaches that don’t require so much manual intervention.

I also see you’re using System.cmd to launch VSCode when you could take the approach from Link Phoenix debug page stack trace to your editor | Angelika.me using a custom url scheme to open most editors. I could see wanting to use Zed for instance but I don’t know if it supports url schemes yet.

I’ve been pining for a LiveView tools chrome extension that is as good as Vue tools but it doesn’t exist. It possibly requires packaging Erlang and Elixir which seems possible but tricky. It may not technically require a runtime if extension points were exposed but I haven’t deeply explored Vue tools to see what would be necessary. This is a great step in that direction though so thanks.

3 Likes

Thanks for sharing this treasure trove of useful debug tools. Really inspiring.

It makes me wonder, how LiveView tooling will look in the browser.

And I’m definitely going to take ideas from Angelika.


Here’s what I’m thinking to add to my project.

An Inspector module that can solve all the problems I face, one by one:

  1. Jump to source code, from a component, or from an error page.
  2. Tell me the Git branch and current release tag. (Helpful for QA, so they can know they are testing the proper release)
  3. Give me all the socket assigns.
  4. Ping latency.

Now, I won’t get lost trying to find the page where Auth-related code exists.

Source Inspector Auth Pages

I’ve really enjoyed this idea! And, true to form, I’ve decided to try and package it into a neat and very opinioinanted library which can be used in any project. You can find it here: GitHub - tmbb/source_inspector: Source Inspector for your LiveView components. Although many steps are still very manual (you need to specify where you want to add “html breakpoint” and which components you want to expose to the source inspector), the library itself doesn’t contain any project-specific code. The HTML representation isn’t actually as pretty, but I think is definitely quite functional.

The main concepts in this library are inspired by the default Elixir debugging features:

  • SourceInspector.inspeectable() (macro) - marks a LiveView component as inspectable. It adds a number of attributes to the component (which are opaque to the user) that allow us to set breakpoints and pry() on the component.

  • SourceInspector.breakpoint() (macro) - establishes a place in an HTML element that can work as a “debugging breakpoint”. When added to an HTML element (not to a component!), it allows the user to right-click the HTML element and open the file that defines the component that returns the HTML.

  • SourceInspector.pry() (macro) - activates the breakpoint(s) in a component: when a component is activated with SourceInspector.pry(), clicking the HTML defined by the element will send the user to the file and line where the component is called (i.e., to teh line where the SourceInspector.pry() macro is used.

This is all a bit abstract, so let’s see (parts of) a practical example:

        @doc """
        Component for a navbar link.
        """

        attr(:active, :boolean, default: false)
        attr(:disabled, :boolean, default: false)
        attr(:class, :string, default: "", doc: "extra classes added to the navbar link")
        attr(:to, :string, doc: "the destination for your navbar link")
        attr(:method, :string, default: "get")

        slot(:inner_block, required: true)

        # mark the component as inspectable
        SourceInspector.inspectable()

        def navbar_link(assigns) do
          unquote_splicing(maybe_raise_not_implemented)
          ~H"""
          <.link class={[
                "nav-link",
                if @active do "active" else "" end,
                if @disabled do "disabled" else "" end,
                @class
              ]}
              href={@to}
              method={@method}
              {SourceInspector.breakpoint()}>
            <%= render_slot(@inner_block) %>
          </.link>
          """
        end
      end

Note how we use SourceInspector.inspectable() to tell the system that we want the component to be inspectable. Then, we set a breakpoint by calling {SourceInspector.breakpoint()} inside the HTML attributes inside the ~H"..." sigil. That code will allow us to pry() into the navbar_link component whenever we call it.

Now, after defining the navbar_link/1 component (either inside or outseide our application), we can use SourceInspect.pry() to activate the Source Inspector on the function calls. For example:

<.navbar {SourceInspector.pry()}>
  <:brand>Demo17</:brand>
  <:start_group>
    <.navbar_link to={~p"/data_entry"} {SourceInspector.pry()}>Home</.navbar_link>
  </:start_group>
  <:end_group>
    <%= if @current_user do %>
      <%= if @current_user.email do %>
        <.navbar_link to={~p"/auth/users/settings"} {SourceInspector.pry()}>Settings</.navbar_link>
        <.navbar_link to={~p"/auth/users/log_out"} method="delete" {SourceInspector.pry()}>Log out</.navbar_link>
      <% end %>
    <% else %>
      <.navbar_link to={~p"/auth/users/register"} {SourceInspector.pry()}>Register</.navbar_link>
      <.navbar_link to={~p"/auth/users/log_in"} {SourceInspector.pry()}>Log in</.navbar_link>
    <% end %>
  </:end_group>
</.navbar>

Note the fragments such as <.navbar_link to={~p"/data_entry"} {SourceInspector.pry()}>Home</.navbar_link>. When we call {SourceInspector.pry()} in the attribute list of the component, this will ensure that each time we right-click on the HTML generated by the component.

For example, see the following screencast.

The output is quite similare to the one shown in the cressncasts in this thread, although not as pretty. The advantage is that my output is generaed by dead-simple CSS and doesn’t’ change the metrics of the eelements by much, which means it probably has less interference with the HTML elements.

3 Likes

Finally, someone with Macro knowledge!

This is exactly why I shared that idea so many times.

So someone with more experience than me, will make it succinct and seamless to use. :star_struck:

@tmbb,

  1. Is it possible to have this enabled for every component? That way, no one has to make a component inspectable or use pry. Every component is supposed to be inspectable. :sunglasses:
  2. Instead of right click, how about the context menu to navigate. (e.g. Similar to the context menu used by LiveBook.)
  3. Using outline instead of border or using the border box css to fix the component from jittering when we hover over them.

P.S. I’m still biased towards a bit of JS, because the tooltip can also contain the name of the component and lead to VS code or GitHub or Storybook.

I believe it’s possible if you’re willing to write a custom EEx engine or a custom sigil_H macro that somehow keeps track of the line no numbers of all function calls and HTML element definitions and maybe keep a call tree in the process dictionary or something like that. Not calling pry() manually means you lose control on which components are meant to be clicked, which can be a bad thing: it might cause you to go somewhere which is “too low level”, like the default live view components or components defined in another app, which is probably not what you want.

I can probably make everything inspectable, but with the current architecture the pry() calls are very important to tell the Source Inspector which file and line should be opened.

As long as you can make the JS code independent of a specific framework (and independent of tailwind!) I’m ok with using JS instead of CSS for the highlights, and even to show a menu. It would have the advantage of not breaking the usual right-click behaviour.

Using outline instead of the border is probably a good idea, yes.

1 Like

NOTE: I can’t think of a way of safely adding support for breakpoints in slots automatically

1 Like

I’m unsure on what is the correct behavior for a source code inspector… When the user clicks on a HTML element, should the source code editor open 1) the line where the HTML element appears (i.e., in the body of the template inside a component), which might be in a dependency of the current application or 2) the function call of the component that renders the HTML element (which is probably ina template inside the user’s application)

IMO the line where the HTML element appears seems more useful, though having the option of both may be good (if using a context menu or something).

I played with the library and defining this macro accomplished that, used by calling it by putting {...pry()} in an element’s HTML attributes (no need for two macros in this case):

  defmacro pry(opts \\ []) do
    quote do
      # Let's see if we want to actually activate Source Inspector or not
      if Application.get_env(:source_inspector, :enabled, false) do
        # The config option is set, so we return a number of attributes
        if Keyword.get(unquote(opts), :random_id, true) == true do
          %{id: Utils.random_string()}
        else
          %{}
        end
        |> Map.merge(%{
          "data-source-inspector": "true",
          "data-source-inspector-file": unquote(__CALLER__.file),
          "data-source-inspector-line": unquote(__CALLER__.line),
          title: SourceInspector.debuggable_element_title(),
          "phx-hook": "SourceInspect"
        })
      else
        # The config option is not set, so we return an empty map
        %{}
      end
    end
  end

PS: I had to remove the Mix.env check since that doesn’t work (always returns :prod) if the component is inside a library.

3 Likes