firehose/app/lib/firehose_web/live/microprints_live.ex
Willem van den Ende 43aaa6bdbe fix(microprints): always show microprint SVG, source below when expanded
Layout: button → microprint (always) → source viewer (conditional)
2026-05-15 16:21:11 +01:00

237 lines
6.2 KiB
Elixir

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()
microprints =
files
|> Enum.map(&process_file/1)
{:ok,
socket
|> assign(:page_title, "Microprints")
|> assign(:microprints, microprints)
|> assign(:expanded_path, nil)
|> assign(:highlighted_path, nil)
|> assign(:highlighted_line, nil)}
rescue
e ->
{:ok,
socket
|> assign(:page_title, "Microprints")
|> assign(:microprints, [])
|> assign(:expanded_path, nil)
|> assign(:highlighted_path, nil)
|> assign(:highlighted_line, nil)
|> put_flash(:error, "Error loading microprints: #{inspect(e)}")}
end
@impl true
def render(assigns) do
~H"""
<div class="max-w-6xl mx-auto">
<h1 class="text-2xl font-bold mb-2">Microprints</h1>
<p class="text-sm text-zinc-500 mb-6">
Visual fingerprints of source code files. Click a line to highlight it.
Click a card to expand and view the source.
</p>
<.microprint_legend />
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6 mt-6">
<%= for %{path: path, microprint: microprint, source: source} = item <- @microprints do %>
{error = item[:error]}
<div class="card bg-base-100 shadow-sm border border-zinc-200">
<div class="card-body p-4">
<h3 class="text-sm font-mono font-medium truncate" title={path}>
{path}
</h3>
<%= if microprint do %>
<button
phx-click="toggle_expand"
phx-value-path={path}
class="btn btn-xs btn-ghost w-full"
>
<%= if @expanded_path == path do %>
Collapse
<% else %>
Expand
<% end %>
</button>
<.microprint
microprint={microprint}
width={200}
max_height={100}
clickable={true}
file_path={path}
highlighted_line={@highlighted_line}
/>
<%= if @expanded_path == path and source do %>
<.source_viewer
content={source}
highlighted_line={@highlighted_line}
language="elixir"
/>
<% end %>
<% else %>
<div class="text-xs text-red-500 mt-1">
Error: {inspect(error)}
</div>
<% end %>
</div>
</div>
<% end %>
</div>
</div>
"""
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
{:noreply,
socket
|> assign(:highlighted_path, path)
|> assign(:highlighted_line, highlighted)}
end
@impl true
def handle_event("toggle_expand", %{"path" => path}, socket) do
expanded =
case socket.assigns.expanded_path do
^path -> nil
_ -> path
end
{:noreply, assign(socket, :expanded_path, expanded)}
end
# Private helpers
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
@doc false
def scan_source_files do
@source_dirs
|> Enum.flat_map(&collect_elixir_files/1)
|> Enum.uniq()
|> Enum.sort()
|> sort_by_mtime()
|> 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(&1, monorepo_root, dir))
false ->
[]
end
end
defp format_path(path, monorepo_root, dir) do
relative = Path.relative_to(path, monorepo_root)
case dir do
"app" ->
String.replace_prefix(relative, "app/", "")
_ ->
"../" <> relative
end
end
defp process_file(rel_path) 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)
# Read source code for expand/contract
source = read_source(abs_path)
%{
path: rel_path,
microprint: Map.put(microprint, :lines, lines_with_numbers),
source: source
}
{:error, reason} ->
%{path: rel_path, microprint: nil, error: reason}
end
end
defp read_source(abs_path) do
case File.read(abs_path) do
{:ok, content} ->
content
|> String.replace("<", "&lt;")
|> String.replace(">", "&gt;")
{:error, _} ->
nil
end
end
# Delegate to MicroprintComponent
defdelegate microprint(assigns), to: MicroprintComponent
defdelegate microprint_legend(assigns), to: MicroprintComponent
defdelegate source_viewer(assigns), to: MicroprintComponent
end