defmodule FirehoseWeb.MicroprintsLive do use FirehoseWeb, :live_view alias Microprints.MicroprintCache alias Microprints.MicroprintComponent @source_dirs ["app", "blogex"] @impl true def mount(params, _session, socket) do files = scan_source_files_with_dir() microprints = files |> Enum.map(&process_file/1) {:ok, socket |> assign(:page_title, "Microprints") |> assign(:microprints, microprints) |> restore_state_from_params(params)} rescue e -> {:ok, socket |> assign(:page_title, "Microprints") |> assign(:microprints, []) |> assign(:expanded_path, nil) |> assign(:source_lines, nil) |> assign(:highlighted_path, nil) |> assign(:highlighted_line, nil) |> put_flash(:error, "Error loading microprints: #{inspect(e)}")} end @impl true def handle_params(params, _uri, socket) do {:noreply, restore_state_from_params(socket, params)} end @impl true def render(assigns) do ~H"""

Microprints

Visual fingerprints of source code files. Click a line to highlight it. Click a card to expand and view the source.

<.microprint_legend />
<%= for %{path: path, microprint: microprint, source_dir: _source_dir} = item <- @microprints do %> {error = item[:error]}

{path}

<%= if microprint do %> <.microprint microprint={microprint} width={200} max_height={100} clickable={true} file_path={path} highlighted_line={@highlighted_line} /> <%= if @expanded_path == path and @source_lines do %> <.source_viewer source_lines={@source_lines} highlighted_line={@highlighted_line} language="elixir" file_path={path} /> <% end %> <% else %>
Error: {inspect(error)}
<% end %>
<% end %>
""" end @impl true def handle_event("highlight_line", %{"line" => line, "path" => path}, socket) do highlighted = case socket.assigns.highlighted_path do ^path -> nil _ -> String.to_integer(line) end # Collapse any currently expanded file when highlighting a different file expanded = case socket.assigns.expanded_path do ^path -> socket.assigns.expanded_path _ -> nil end socket = socket |> assign(:highlighted_path, path) |> assign(:highlighted_line, highlighted) |> assign(:expanded_path, expanded) |> assign(:source_lines, nil) {:noreply, push_patch(socket, to: build_params(socket))} end @impl true def handle_event("toggle_expand", %{"path" => path}, socket) do expanded = case socket.assigns.expanded_path do ^path -> nil _ -> path end source_lines = case expanded do nil -> nil ^path -> item = Enum.find(socket.assigns.microprints, &(&1.path == path)) if item && item.source_dir do abs_path = resolve_source_path(item.source_dir, path) read_source(abs_path) else nil end end socket = socket |> assign(:expanded_path, expanded) |> assign(:source_lines, source_lines) {:noreply, push_patch(socket, to: build_params(socket))} end # Private helpers defp restore_state_from_params(socket, params) do expanded_path = params["expanded"] highlighted_path = params["highlighted"] highlighted_line = if params["line"], do: String.to_integer(params["line"]), else: nil source_lines = if expanded_path do item = Enum.find(socket.assigns.microprints, &(&1.path == expanded_path)) if item && item.source_dir do abs_path = resolve_source_path(item.source_dir, expanded_path) read_source(abs_path) end end socket |> assign(:expanded_path, expanded_path) |> assign(:source_lines, source_lines) |> assign(:highlighted_path, highlighted_path) |> assign(:highlighted_line, highlighted_line) end defp build_params(socket) do params = %{} params = if socket.assigns.expanded_path do Map.put(params, "expanded", socket.assigns.expanded_path) else params end params = if socket.assigns.highlighted_path do Map.put(params, "highlighted", socket.assigns.highlighted_path) else params end params = if socket.assigns.highlighted_line do params |> Map.put("line", socket.assigns.highlighted_line) else params end "/microprints" <> if params != %{}, do: "?" <> URI.encode_query(params), else: "" end defp sort_by_mtime(files) do app_root = Mix.Project.project_file() |> Path.dirname() files |> Enum.map(fn file -> abs_path = Path.join(app_root, file) {file, File.stat!(abs_path).mtime} end) |> Enum.sort_by(fn {_file, mtime} -> mtime end, :desc) |> Enum.map(fn {file, _mtime} -> file end) end defp sort_by_mtime_with_dir(tuples) do app_root = Mix.Project.project_file() |> Path.dirname() tuples |> Enum.map(fn {path, dir} -> abs_path = Path.join(app_root, path) {{path, dir}, File.stat!(abs_path).mtime} end) |> Enum.sort_by(fn {{_path, _dir}, mtime} -> mtime end, :desc) |> Enum.map(fn {{path, dir}, _mtime} -> {path, dir} end) end @doc false def scan_source_files do @source_dirs |> Enum.flat_map(&collect_elixir_files/1) |> Enum.uniq_by(&elem(&1, 0)) |> Enum.map(&elem(&1, 0)) |> Enum.sort() |> sort_by_mtime() |> Enum.take(2) end @doc false def scan_source_files_with_dir do @source_dirs |> Enum.flat_map(&collect_elixir_files/1) |> Enum.uniq_by(&elem(&1, 0)) |> Enum.sort_by(&elem(&1, 0)) |> sort_by_mtime_with_dir() |> Enum.take(2) end defp resolve_absolute_paths(files) do app_root = Mix.Project.project_file() |> Path.dirname() Enum.map(files, fn path -> Path.expand(Path.join(app_root, path)) end) end defp excluded_path?(path) do path =~ "/_build/" or path =~ "/deps/" or path =~ "/examples/" or path =~ "/test/" or path =~ "/lib_dev/" end defp collect_elixir_files(dir) do app_root = Mix.Project.project_file() |> Path.dirname() monorepo_root = app_root |> Path.dirname() base = Path.join(monorepo_root, dir) case File.dir?(base) do true -> base |> Path.join("**/*.ex") |> Path.wildcard() |> Enum.filter(&File.regular?(&1)) |> Enum.reject(&excluded_path?/1) |> Enum.map(&format_path_and_dir(&1, monorepo_root, dir)) false -> [] end end defp format_path_and_dir(path, monorepo_root, dir) do relative = Path.relative_to(path, monorepo_root) formatted = case dir do "app" -> String.replace_prefix(relative, "app/", "") _ -> "../" <> relative end {formatted, dir} end defp process_file({rel_path, source_dir}) do abs_path = resolve_absolute_paths([rel_path]) |> List.first() case MicroprintCache.get_microprint(abs_path) do {:ok, microprint} -> # Add line numbers to each line for highlighting lines_with_numbers = microprint.lines |> Enum.with_index(1) |> Enum.map(fn {line, num} -> Map.put(line, :line_number, num) end) %{ path: rel_path, source_dir: source_dir, microprint: Map.put(microprint, :lines, lines_with_numbers) } {:error, reason} -> %{path: rel_path, microprint: nil, error: reason} end end defp resolve_source_path(source_dir, rel_path) do app_root = Mix.Project.project_file() |> Path.dirname() monorepo_root = app_root |> Path.dirname() base = Path.join(monorepo_root, source_dir) Path.join(base, rel_path) end defp read_source(abs_path) do case File.read(abs_path) do {:ok, content} -> String.split(content, "\n") {:error, _} -> nil end end # Delegate to MicroprintComponent defdelegate microprint(assigns), to: MicroprintComponent defdelegate microprint_legend(assigns), to: MicroprintComponent # Custom source_viewer with unique DOM IDs per file to prevent LiveView # DOM patching bugs when switching expanded files. def source_viewer(assigns) do assigns = assign(assigns, :viewer_id, "source-viewer-" <> Integer.to_string(:erlang.phash2(assigns.file_path, 1_000_000))) ~H"""
<%= for {line, num} <- Enum.with_index(@source_lines || [], 1) do %>
if @highlighted_line == num, do: " sv-line-highlighted", else: ""} > <%= num %> <%= line %>
<% end %>
""" end end