7.3 KiB · text 5af55d9
defmodule GitGudWeb.DiffComponents do
@moduledoc """
Renders parsed diffs (see `GitGud.Git.DiffParser`) as HTML.
Unified layout: each hunk is a `<table>` with three columns —
old-line number, new-line number, content. Add / delete rows get
green / red row backgrounds keyed off daisyUI's `--color-success` /
`--color-error` tokens.
Each line's content is fed through `GitGud.SyntaxHighlight.highlight/2`
with the file path as the language hint. Per-line highlighting isn't
context-aware (a multi-line string opened on one line stops at the
newline), but for diff display it's the right granularity: each line
has its own gutter color and the syntax of one line rarely depends
on context past it. Cached the same way as full-file highlighting.
"""
use Phoenix.Component
alias GitGud.Git.DiffParser
alias GitGud.SyntaxHighlight
@doc """
Render a list of `%DiffParser.file_diff{}` maps.
<.diff files={@diff} theme={@editor_theme} loaded_idx={@loaded_diff_idx} />
Files whose index is in `loaded_idx` get their hunks rendered into the
DOM and `<details>` opened. Others render only the header (summary)
with a placeholder, and emit `expand_diff_file` with the file index
when the user clicks the summary.
Use `default_loaded/1` to derive an initial set sized by total file
count — small diffs preload everything, large diffs preload only the
first couple.
"""
attr :files, :list, required: true
attr :theme, :string, default: "onedark"
attr :loaded_idx, :any, default: nil
def diff(assigns) do
loaded = assigns[:loaded_idx] || default_loaded(assigns.files)
assigns = assign(assigns, :loaded_idx, loaded)
~H"""
<div class="space-y-4">
<p :if={@files == []} class="text-sm opacity-60">No changes.</p>
<.file_diff
:for={{file, idx} <- Enum.with_index(@files)}
file={file}
theme={@theme}
idx={idx}
loaded?={MapSet.member?(@loaded_idx, idx)}
/>
</div>
"""
end
@doc """
Default initial-loaded set: small diffs preload all files, larger ones
taper off. Returns a `MapSet` of integer indices.
"""
@spec default_loaded([DiffParser.file_diff()] | any()) :: MapSet.t(non_neg_integer())
def default_loaded(files) when is_list(files) do
n =
case length(files) do
n when n <= 10 -> n
n when n <= 50 -> 5
_ -> 2
end
MapSet.new(0..(n - 1)//1)
end
def default_loaded(_), do: MapSet.new(0..4//1)
@doc """
Wire an `expand_diff_file` handler in a LiveView with one line:
def handle_event("expand_diff_file", params, socket),
do: {:noreply, DiffComponents.expand(socket, params)}
Adds the clicked file's index to `socket.assigns.loaded_diff_idx` so
the next render injects its hunks into the DOM.
"""
@spec expand(Phoenix.LiveView.Socket.t(), map()) :: Phoenix.LiveView.Socket.t()
def expand(socket, %{"idx" => idx}) do
case Integer.parse(to_string(idx)) do
{i, ""} ->
Phoenix.Component.update(socket, :loaded_diff_idx, &MapSet.put(&1, i))
_ ->
socket
end
end
attr :file, :map, required: true
attr :theme, :string, required: true
attr :idx, :integer, required: true
attr :loaded?, :boolean, default: true
defp file_diff(assigns) do
~H"""
<details
class="border border-base-300 rounded overflow-hidden"
open={@loaded?}
id={"file-diff-#{@idx}"}
>
<summary
class="flex items-baseline justify-between gap-3 px-3 py-2 bg-base-200 cursor-pointer"
phx-click="expand_diff_file"
phx-value-idx={@idx}
>
<div class="flex items-baseline gap-2 min-w-0">
<span class={["badge badge-xs", status_class(@file.status)]}>{@file.status}</span>
<span class="font-mono text-sm truncate">
<span :if={@file.old_path} class="opacity-60">{@file.old_path}</span>{@file.path}
</span>
</div>
<div class="text-xs whitespace-nowrap opacity-70">
<span class="text-success">+{@file.insertions}</span>
<span class="text-error ml-1">{@file.deletions}</span>
</div>
</summary>
<%= cond do %>
<% not @loaded? -> %>
<p class="p-3 text-xs opacity-60">Click to load diff…</p>
<% @file.binary? -> %>
<p class="p-3 text-sm opacity-60">Binary file — not rendered.</p>
<% @file.hunks == [] -> %>
<p class="p-3 text-sm opacity-60">No textual hunks.</p>
<% true -> %>
<.hunks file={@file} theme={@theme} />
<% end %>
</details>
"""
end
attr :file, :map, required: true
attr :theme, :string, required: true
defp hunks(assigns) do
~H"""
<div class="text-xs font-mono">
<%= for hunk <- @file.hunks do %>
<div class="bg-base-100 text-base-content/60 px-3 py-1 border-y border-base-300">
{hunk.header}
</div>
<table class="w-full border-collapse">
<tbody>
<tr :for={line <- hunk.lines} class={row_class(line.type)}>
<td class="text-right px-2 py-0.5 opacity-50 select-none w-12 border-r border-base-300">
{line.old_line}
</td>
<td class="text-right px-2 py-0.5 opacity-50 select-none w-12 border-r border-base-300">
{line.new_line}
</td>
<td class="px-2 py-0.5 whitespace-pre-wrap break-all">
<span class="opacity-60 select-none mr-1">{prefix_char(line.type)}</span>{highlight_line(line.text, @file.path, @theme)}
</td>
</tr>
</tbody>
</table>
<% end %>
</div>
"""
end
defp highlight_line("", _path, _theme), do: ""
defp highlight_line(text, path, theme) do
text
|> SyntaxHighlight.highlight(filename: path, theme: theme)
|> strip_pre_wrapper()
|> Phoenix.HTML.raw()
end
# Lumis wraps the output in `<pre class="lumis"><code>...</code></pre>`
# — strip both so the result drops inline into our diff cell without
# double-pre styling.
defp strip_pre_wrapper(html) when is_binary(html) do
html
|> String.replace(~r/^<pre[^>]*>/, "")
|> String.replace(~r/<\/pre>$/, "")
|> String.replace(~r/^<code[^>]*>/, "")
|> String.replace(~r/<\/code>$/, "")
|> String.replace(~r/^<div[^>]*>/, "")
|> String.replace(~r/<\/div>\s*$/, "")
end
defp row_class(:add),
do: "bg-success/10"
defp row_class(:del),
do: "bg-error/10"
defp row_class(_), do: ""
defp prefix_char(:add), do: "+"
defp prefix_char(:del), do: "−"
defp prefix_char(_), do: " "
defp status_class(:added), do: "badge-success"
defp status_class(:deleted), do: "badge-error"
defp status_class(:renamed), do: "badge-info"
defp status_class(:binary), do: "badge-neutral"
defp status_class(_), do: ""
@doc """
Sum of additions / deletions across a parsed diff. Useful for the
header above the diff renderer when you don't already have stats.
"""
@spec totals([DiffParser.file_diff()]) :: %{
files: non_neg_integer(),
insertions: non_neg_integer(),
deletions: non_neg_integer()
}
def totals(files) do
Enum.reduce(files, %{files: 0, insertions: 0, deletions: 0}, fn f, acc ->
%{
files: acc.files + 1,
insertions: acc.insertions + f.insertions,
deletions: acc.deletions + f.deletions
}
end)
end
end