empowered.house ↗

git/diff.ex

Diff

A file diff with a file header, line numbers, removed and added lines, unified or split.

Use it
# in the module that renders the template
use EHVisuals

<div class="h-96 rounded-xl border border-border">
  <.git_diff animated />
</div>
default
split
isometric
a config change · custom copy
split · light surface
isometric · fade out · still

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
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
diff.css
/* git/diff */
.ehv-git-diff { --ehv-max-w: 460px; --ehv-step: 70ms; }