- Use Path.expand(Path.join(app_root, path)) to correctly resolve both app files (lib/...) and blogex files (../blogex/...) - Previously Path.join alone did not resolve '..' components, causing all source viewers to show the wrong file content
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 %>
|
|
<.microprint
|
|
microprint={microprint}
|
|
width={200}
|
|
max_height={100}
|
|
clickable={true}
|
|
file_path={path}
|
|
highlighted_line={@highlighted_line}
|
|
/>
|
|
|
|
<button
|
|
phx-click="toggle_expand"
|
|
phx-value-path={path}
|
|
class="btn btn-xs btn-ghost mt-2 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"
|
|
/>
|
|
<% 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
|