Replace double-quoted string with escaped quotes in element selector with a ~s sigil to satisfy Credo's 'More than 3 quotes found inside string literal' readability check. Includes formatting changes from mix format (ran via make check).
375 lines
10 KiB
Elixir
375 lines
10 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_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"""
|
|
<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_dir: _source_dir} = 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_lines do %>
|
|
<.source_viewer
|
|
source_lines={@source_lines}
|
|
highlighted_line={@highlighted_line}
|
|
language="elixir"
|
|
file_path={path}
|
|
/>
|
|
<% 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
|
|
|
|
# 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"""
|
|
<div
|
|
id={@viewer_id}
|
|
class="source-viewer mt-2 max-h-96 overflow-auto rounded font-mono text-xs"
|
|
phx-hook="SourceViewer"
|
|
data-highlighted-line={@highlighted_line}
|
|
data-language={@language}
|
|
style="background: var(--sv-bg); color: var(--sv-text);"
|
|
>
|
|
<div class="sv-lines p-2">
|
|
<%= for {line, num} <- Enum.with_index(@source_lines || [], 1) do %>
|
|
<div
|
|
id={"line-#{num}"}
|
|
class={"sv-line" <> if @highlighted_line == num, do: " sv-line-highlighted", else: ""}
|
|
>
|
|
<span class="sv-line-number">{num}</span>
|
|
<span class="sv-line-content">{line}</span>
|
|
</div>
|
|
<% end %>
|
|
</div>
|
|
</div>
|
|
"""
|
|
end
|
|
end
|