git/diff.ex
Diff
A file diff with a file header, line numbers, removed and added lines, unified or split.
# in the module that renders the template
use EHVisuals
<div class="h-96 rounded-xl border border-border">
<.git_diff animated />
</div>
Props
| Prop | Type | Default | What it does |
|---|---|---|---|
file |
string | "lib/orders/approval.ex" |
Up to 40 characters. |
lines |
list | [%{type: "context", text: "def approve(order) do"}, %{type: "removed", text: " limit = 5_000"}, ...] |
Up to twelve `%{type, text}`. Type is `context`, `removed` or `added`. Text up to 54 characters, 30 in the split layout. Leading spaces indent the line. |
layout |
"unified" | "split" | "unified" |
`unified` is one column, `split` puts the old file left and the new one right. |
start |
integer | 14 |
Number of the first line. |
Frame props, shared by every composition
| Prop | Type | Default | What it does |
|---|---|---|---|
animated |
boolean | false |
Turns motion on. Without it the composition renders in its final, still state. |
trigger |
"mount" | "in-view" | "in-view-repeat" | "in-view" |
When the entrance plays: on page load, once when scrolled into view, or every time it comes back into view. |
hover |
boolean | false |
Enables the hover micro-interactions the composition supports. |
glow |
boolean | true |
The warm incandescent halo behind the composition. |
fade_out |
boolean | false |
Fades the bottom edge into the surface underneath. |
isometric |
boolean | false |
Tilts the composition into the isometric plane of the platform stack. |
theme |
"dark" | "light" | "dark" |
`dark` for the site canvas, `light` for the cream reversed surface. |
class |
string | nil |
Extra classes for the frame. |
rest |
global | — |
Any other HTML attribute, set on the frame. |
Source
Two files. Copy them into your app if you would rather own the code than depend on the library.
lib/eh_visuals/git/diff.ex
defmodule EHVisuals.Git.Diff do
@moduledoc """
One hunk of a file diff: the file header, line numbers, removed lines and
added lines, either in one column or side by side.
"""
use EHVisuals.Composition,
name: "git/diff",
title: "Diff",
description:
"A file diff with a file header, line numbers, removed and added lines, unified or split."
@lines [
%{type: "context", text: "def approve(order) do"},
%{type: "removed", text: " limit = 5_000"},
%{type: "added", text: " limit = Limits.for(order)"},
%{type: "context", text: " if order.total > limit do"},
%{type: "removed", text: " {:error, :too_large}"},
%{type: "added", text: " ask_manager(order)"},
%{type: "context", text: " else"},
%{type: "context", text: " post_to_erp(order)"}
]
attr(:file, :string, default: "lib/orders/approval.ex", doc: "Up to 40 characters.")
attr(:lines, :list,
default: @lines,
doc:
"Up to twelve `%{type, text}`. Type is `context`, `removed` or `added`. Text up to 54 characters, 30 in the split layout. Leading spaces indent the line."
)
attr(:layout, :string,
default: "unified",
values: ~w(unified split),
doc: "`unified` is one column, `split` puts the old file left and the new one right."
)
attr(:start, :integer, default: 14, doc: "Number of the first line.")
attr(:label, :string,
default: "A file diff with two removed lines in coral and two added lines in lime"
)
frame_attrs()
@spec git_diff(map()) :: Phoenix.LiveView.Rendered.t()
def git_diff(assigns) do
lines = assigns.lines |> Enum.take(12) |> Enum.map(&measure/1)
removed = Enum.count(lines, &(&1.type == "removed"))
added = Enum.count(lines, &(&1.type == "added"))
start = assigns.start
split = assigns.layout == "split"
rows =
if split do
lines |> pair() |> number_pairs(start)
else
number_lines(lines, start)
end
hunk =
"@@ -#{start},#{length(lines) - added} +#{start},#{length(lines) - removed} @@"
assigns =
assigns
|> assign(:split, split)
|> assign(:rows, Enum.with_index(rows))
|> assign(:added, added)
|> assign(:removed, removed)
|> assign(:hunk, hunk)
|> assign(:height, 36 + 22 + length(rows) * 20 + 12)
~H"""
<.frame name="git/diff" label={@label} {frame_opts(assigns)}>
<svg viewBox={"0 0 420 #{@height}"}>
<g class="ehv-h-lift">
<rect class="ehv-card ehv-a-rise" x="0.5" y="0.5" width="419" height={@height - 1} rx="12" />
<g class="ehv-a-fade ehv-i-1">
<path class="ehv-line ehv-round" d="M17.5 11.5 h6 l4 4 v9 h-10 z m6 0 v4 h4" />
<text class="ehv-code ehv-t-2" x="36" y="22" font-size="10">{@file}</text>
<text class="ehv-mono ehv-t-coral" x="404" y="22" font-size="9" text-anchor="end">
-{@removed}
</text>
<text class="ehv-mono ehv-t-lime" x="382" y="22" font-size="9" text-anchor="end">
+{@added}
</text>
<line class="ehv-line-soft" x1="0.5" x2="419.5" y1="35.5" y2="35.5" />
<rect class="ehv-fill-surface-2" x="1" y="36" width="418" height="22" />
<text class="ehv-code ehv-t-3" x="16" y="50.5" font-size="8.5">{@hunk}</text>
<line class="ehv-line-faint" x1="0.5" x2="419.5" y1="58.5" y2="58.5" />
<line
:if={@split}
class="ehv-line-faint"
x1="210.5"
x2="210.5"
y1="58.5"
y2={@height - 1}
/>
</g>
<g
:for={{row, index} <- @rows}
:if={not @split}
class={"ehv-a-fade ehv-i-#{min(index + 2, 24)}"}
>
<rect
:if={row.type != "context"}
class={tint(row.type)}
x="1"
y={59 + index * 20}
width="418"
height="20"
/>
<text
class="ehv-code ehv-t-3"
x="30"
y={73 + index * 20}
font-size="8.5"
text-anchor="end"
>
{row.old}
</text>
<text
class="ehv-code ehv-t-3"
x="56"
y={73 + index * 20}
font-size="8.5"
text-anchor="end"
>
{row.new}
</text>
<text class={["ehv-code", sign_tone(row.type)]} x="68" y={73 + index * 20} font-size="9.5">
{sign(row.type)}
</text>
<text
class={["ehv-code", row.type == "context" && "ehv-t-2"]}
x={84 + row.indent * 5.7}
y={73 + index * 20}
font-size="9.5"
>
{row.text}
</text>
</g>
<g
:for={{row, index} <- @rows}
:if={@split}
class={"ehv-a-fade ehv-i-#{min(index + 2, 24)}"}
>
<g :if={row.left}>
<rect
:if={row.left.type != "context"}
class={tint(row.left.type)}
x="1"
y={59 + index * 20}
width="209"
height="20"
/>
<text
class="ehv-code ehv-t-3"
x="28"
y={73 + index * 20}
font-size="8.5"
text-anchor="end"
>
{row.old}
</text>
<text
class={["ehv-code", row.left.type == "context" && "ehv-t-2"]}
x={40 + row.left.indent * 5.1}
y={73 + index * 20}
font-size="8.5"
>
{row.left.text}
</text>
</g>
<g :if={row.right}>
<rect
:if={row.right.type != "context"}
class={tint(row.right.type)}
x="211"
y={59 + index * 20}
width="208"
height="20"
/>
<text
class="ehv-code ehv-t-3"
x="238"
y={73 + index * 20}
font-size="8.5"
text-anchor="end"
>
{row.new}
</text>
<text
class={["ehv-code", row.right.type == "context" && "ehv-t-2"]}
x={250 + row.right.indent * 5.1}
y={73 + index * 20}
font-size="8.5"
>
{row.right.text}
</text>
</g>
</g>
</g>
</svg>
</.frame>
"""
end
defp tint("removed"), do: "ehv-fill-coral-soft"
defp tint(_type), do: "ehv-fill-lime-soft"
defp sign("removed"), do: "-"
defp sign("added"), do: "+"
defp sign(_type), do: ""
defp sign_tone("removed"), do: "ehv-t-coral"
defp sign_tone(_type), do: "ehv-t-lime"
# SVG text drops leading spaces, so the indent becomes an x offset.
defp measure(line) do
text = String.trim_leading(line.text)
%{type: line.type, text: text, indent: String.length(line.text) - String.length(text)}
end
defp number_lines(lines, start) do
{rows, _counters} =
Enum.map_reduce(lines, {start, start}, fn line, {old, new} ->
case line.type do
"removed" -> {Map.merge(line, %{old: old, new: nil}), {old + 1, new}}
"added" -> {Map.merge(line, %{old: nil, new: new}), {old, new + 1}}
_context -> {Map.merge(line, %{old: old, new: new}), {old + 1, new + 1}}
end
end)
rows
end
# Split layout: a run of removed lines sits next to the run of added lines
# that replaces it. Context lines appear on both sides.
defp pair([%{type: "removed"} | _rest] = lines) do
{removed, rest} = Enum.split_while(lines, &(&1.type == "removed"))
{added, rest} = Enum.split_while(rest, &(&1.type == "added"))
rows = max(length(removed), length(added))
for(index <- 0..(rows - 1), do: {Enum.at(removed, index), Enum.at(added, index)}) ++
pair(rest)
end
defp pair([%{type: "added"} = line | rest]), do: [{nil, line} | pair(rest)]
defp pair([line | rest]), do: [{line, line} | pair(rest)]
defp pair([]), do: []
defp number_pairs(pairs, start) do
{rows, _counters} =
Enum.map_reduce(pairs, {start, start}, fn {left, right}, {old, new} ->
row = %{left: left, right: right, old: left && old, new: right && new}
{row, {if(left, do: old + 1, else: old), if(right, do: new + 1, else: new)}}
end)
rows
end
end
assets/css/git/diff.css
/* git/diff */
.ehv-git-diff { --ehv-max-w: 460px; --ehv-step: 70ms; }