defmodule GitGudWeb.EventComponents do
@moduledoc """
Shared pieces for issue and pull request history: the editable title
on the show pages, and the timeline the history subpages render.
Both targets carry the same fields here, so the components take a
plain title/number rather than an `%Issue{}` or `%PullRequest{}`.
"""
use GitGudWeb, :html
alias GitGud.Git
@doc """
The `#N — Title` heading, with an inline edit form when the viewer
may rename it.
The parent LiveView owns the state: it handles `edit_title`,
`cancel_edit_title` and `save_title`, and passes `editing?`.
"""
attr :number, :integer, required: true
attr :title, :string, required: true
attr :editing?, :boolean, default: false
attr :can_edit?, :boolean, default: false
attr :error, :string, default: nil
def editable_title(assigns) do
~H"""
<%= if @editing? do %>
<form phx-submit="save_title" class="flex items-start gap-2">
<div class="flex-1">
<input
type="text"
name="title"
value={@title}
required
maxlength="255"
autofocus
aria-label="Title"
class="input input-bordered w-full text-xl font-semibold"
/>
<p :if={@error} class="text-xs text-error mt-1">{@error}</p>
</div>
<button type="submit" class="btn btn-sm btn-primary">Save</button>
<button type="button" phx-click="cancel_edit_title" class="btn btn-sm btn-ghost">
Cancel
</button>
</form>
<% else %>
<div class="flex items-baseline gap-2">
<h1 class="text-2xl font-semibold">
#{@number} — {@title}
</h1>
<button
:if={@can_edit?}
type="button"
phx-click="edit_title"
class="btn btn-xs btn-ghost"
aria-label="Edit title"
>
<.icon name="hero-pencil-square" class="size-4" /> Edit
</button>
</div>
<% end %>
"""
end
@doc "The history timeline — one row per recorded event, oldest first."
attr :events, :list, required: true
def timeline(assigns) do
~H"""
<p :if={@events == []} class="py-12 text-center text-sm opacity-60">
Nothing recorded yet. History starts when something happens — a push, a
rename, a label, a state change.
</p>
<ol :if={@events != []} class="relative border-l border-base-300 ml-2 space-y-4 py-2">
<li :for={event <- @events} id={"event-#{event.id}"} class="ml-4">
<span class={[
"absolute -left-1.5 size-3 rounded-full border-2 border-base-100",
dot_class(event.kind)
]} />
<p class="text-sm">
<span class="font-medium">{actor_name(event)}</span>
{describe(event)}
</p>
<p class="text-xs opacity-50 mt-0.5" title={exact_time(event.inserted_at)}>
{format_dt(event.inserted_at)}
</p>
<.body_diff event={event} />
</li>
</ol>
"""
end
# The before/after of a comment edit, folded away by default. Kept
# out of the `<p>` above because `<details>` can't live inside one.
#
# This renders from the event payload, not from the comment row, so
# it still works after the comment is deleted.
attr :event, :map, required: true
defp body_diff(assigns) do
~H"""
<details
:if={diffable?(@event)}
id={"event-#{@event.id}-diff"}
class="mt-1 text-xs"
>
<summary class="cursor-pointer opacity-60 hover:opacity-100 select-none">
Show what changed
</summary>
<div class="mt-1 rounded border border-base-300 overflow-x-auto">
<div
:for={{marker, line} <- diff_lines(@event)}
class={["font-mono whitespace-pre px-2 py-0.5", diff_class(marker)]}
>
{marker}{line}
</div>
</div>
</details>
"""
end
defp diffable?(%{kind: kind, data: %{"from" => f, "to" => t}})
when kind in ["comment_edited", "description_changed"] and is_binary(f) and is_binary(t),
do: true
defp diffable?(_), do: false
# `List.myers_difference/2` over lines gives the same shape a unified
# diff wants: runs of equal, deleted and inserted lines in order.
defp diff_lines(%{data: %{"from" => from, "to" => to}}) do
List.myers_difference(String.split(from, "\n"), String.split(to, "\n"))
|> Enum.flat_map(fn
{:eq, lines} -> Enum.map(lines, &{" ", &1})
{:del, lines} -> Enum.map(lines, &{"-", &1})
{:ins, lines} -> Enum.map(lines, &{"+", &1})
end)
end
defp diff_class("+"), do: "bg-success/15 text-success-content"
defp diff_class("-"), do: "bg-error/15 text-error-content"
defp diff_class(_), do: "opacity-70"
defp review_word("approved"), do: "approved"
defp review_word("changes_requested"), do: "changes requested"
defp review_word("commented"), do: "commented"
defp review_word(_), do: nil
defp dot_class("description_changed"), do: "bg-base-300"
defp dot_class(k) when k in ["reviewed", "review_requested"], do: "bg-accent"
defp dot_class("review_request_removed"), do: "bg-base-300"
defp dot_class("suggestion_applied"), do: "bg-success"
defp dot_class(k) when k in ["comment_added", "comment_edited"], do: "bg-info"
defp dot_class("comment_deleted"), do: "bg-error"
defp dot_class("merged"), do: "bg-secondary"
defp dot_class("closed"), do: "bg-error"
defp dot_class("reopened"), do: "bg-success"
defp dot_class("opened"), do: "bg-success"
defp dot_class("pushed"), do: "bg-primary"
defp dot_class(k) when k in ["moderated", "unmoderated"], do: "bg-warning"
defp dot_class(_), do: "bg-base-300"
# A push has no user attached; a federated action has a remote actor
# rather than a local one. Both land here as nil.
defp actor_name(%{actor: %{handle: h}}) when is_binary(h), do: h
defp actor_name(%{kind: "pushed"}), do: "A push"
defp actor_name(_), do: "Someone"
defp describe(%{kind: "opened"}), do: "opened this."
defp describe(%{kind: "closed"}), do: "closed this."
defp describe(%{kind: "reopened"}), do: "reopened this."
defp describe(%{kind: "merged"}), do: "merged this."
defp describe(%{kind: "title_changed", data: %{"from" => from, "to" => to}}) do
assigns = %{from: from, to: to}
~H"""
changed the title from <span class="line-through opacity-60">{@from}</span>
to <span class="font-medium">{@to}</span>.
"""
end
defp describe(%{kind: "title_changed"}), do: "changed the title."
defp describe(%{kind: "description_changed"}), do: "edited the description."
defp describe(%{kind: "pushed", data: data}) do
assigns = %{
from: short(data["from"]),
to: short(data["to"]),
commits: data["commits"]
}
~H"""
updated the head <span :if={@from}>from <code class="font-mono text-xs">{@from}</code></span>
to
<code class="font-mono text-xs">{@to}</code><span :if={is_integer(@commits) and @commits > 0}>, {@commits} new commit(s)</span>.
"""
end
defp describe(%{kind: kind, data: %{"name" => name}}) when kind in ["labeled", "unlabeled"] do
verb = if kind == "labeled", do: "added", else: "removed"
assigns = %{name: name, verb: verb}
~H"""
{@verb} the <span class="badge badge-xs">{@name}</span> label.
"""
end
defp describe(%{kind: "labeled"}), do: "added a label."
defp describe(%{kind: "unlabeled"}), do: "removed a label."
defp describe(%{kind: "reviewed", data: %{"to" => to} = data}) do
assigns = %{to: review_word(to), from: review_word(data["from"])}
~H"""
<span :if={@from}>
changed their review from {@from} to <span class="font-medium">{@to}</span>.
</span>
<span :if={is_nil(@from)}>reviewed: <span class="font-medium">{@to}</span>.</span>
"""
end
defp describe(%{kind: "reviewed"}), do: "reviewed this."
defp describe(%{kind: "review_requested", data: %{"reviewer" => who}}) when is_binary(who),
do: "asked #{who} to review."
defp describe(%{kind: "review_requested"}), do: "requested a review."
defp describe(%{kind: "review_request_removed", data: %{"reviewer" => who}})
when is_binary(who),
do: "withdrew the review request for #{who}."
defp describe(%{kind: "review_request_removed"}), do: "withdrew a review request."
defp describe(%{kind: "suggestion_applied", data: %{"file_path" => path, "line" => line}}),
do: "applied a suggestion to #{path}:#{line}."
defp describe(%{kind: "suggestion_applied"}), do: "applied a suggestion."
defp describe(%{kind: "comment_added"}), do: "commented."
defp describe(%{kind: "comment_edited"}), do: "edited a comment."
defp describe(%{kind: "comment_deleted"}), do: "deleted a comment."
defp describe(%{kind: "moderated", data: %{"note" => note}})
when is_binary(note) and note != "",
do: "moderated this: #{note}"
defp describe(%{kind: "moderated"}), do: "moderated this."
defp describe(%{kind: "unmoderated"}), do: "restored this."
defp describe(%{kind: kind}), do: kind
# `data` holds hex strings — the sha columns are binary, but an event
# payload is JSON, so callers encode before recording.
defp short(hex) when is_binary(hex) and byte_size(hex) >= 7, do: binary_part(hex, 0, 7)
defp short(hex) when is_binary(hex), do: hex
defp short(_), do: nil
defp format_dt(dt), do: Calendar.strftime(dt, "%Y-%m-%d %H:%M")
defp exact_time(dt), do: Calendar.strftime(dt, "%Y-%m-%d %H:%M:%S UTC")
@doc "Hex-encode a sha for an event payload, tolerating nil."
def hex(nil), do: nil
def hex(sha) when is_binary(sha), do: Git.to_hex(sha)
end
neiam /gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Packages
⭐
Log in to mark this repository.
9.4 KiB · text
History
51deb4e