- Button is now on top of the card, matching qwan-tracker layout - Microprint SVG shown when collapsed, source viewer when expanded - Fixes DOM confusion from duplicate source-viewer IDs by only rendering one source_viewer at a time
237 lines
6.2 KiB
Elixir
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>
|
|
|
|
<%= if @expanded_path == path and source do %>
|
|
<.source_viewer
|
|
content={source}
|
|
highlighted_line={@highlighted_line}
|
|
language="elixir"
|
|
/>
|
|
<% else %>
|
|
<.microprint
|
|
microprint={microprint}
|
|
width={200}
|
|
max_height={100}
|
|
clickable={true}
|
|
file_path={path}
|
|
highlighted_line={@highlighted_line}
|
|
/>
|
|
<% 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("<", "<")
|
|
|> String.replace(">", ">")
|
|
|
|
{: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
|