Fresh repository history for elektrine/tarakan hosted at https://git.elektrine.com/elektrine/tarakan.
322 lines
9.3 KiB
Elixir
322 lines
9.3 KiB
Elixir
defmodule TarakanWeb.RepositoryCommitsLive do
|
|
@moduledoc """
|
|
Commit history of a repository's branches, plus a per-commit detail view
|
|
with the full message and patch.
|
|
|
|
Every listed commit is reachable from a published branch tip, which is
|
|
exactly the set `RepositoryCode.authorize_public_commit/2` allows in the
|
|
code browser - so each commit links straight into the pinned source view.
|
|
"""
|
|
|
|
use TarakanWeb, :live_view
|
|
|
|
alias Tarakan.Repositories
|
|
alias Tarakan.Repositories.Repository
|
|
alias Tarakan.RepositoryCode
|
|
alias Tarakan.RepositoryCode.Commit
|
|
alias TarakanWeb.RepositoryPaths
|
|
|
|
@page_size 50
|
|
@max_diff_lines 5_000
|
|
@commit_sha_pattern ~r/^[0-9a-f]{40}$/
|
|
|
|
@impl true
|
|
def mount(params, _session, socket) do
|
|
case repository_from_params(params, socket) do
|
|
%Repository{} = repository ->
|
|
if connected?(socket) do
|
|
Repositories.subscribe()
|
|
end
|
|
|
|
{:ok,
|
|
socket
|
|
|> assign(:repository, repository)
|
|
|> assign(:view_state, :loading)
|
|
|> assign(:error_kind, nil)
|
|
|> assign(:limit, @page_size)
|
|
|> assign(:has_more?, false)
|
|
|> assign(:branch_options, [])
|
|
|> assign(:selected_branch, repository.default_branch)
|
|
|> assign(:commit_sha, nil)
|
|
|> assign(:commit, nil)
|
|
|> maybe_load_branch_options()
|
|
|> stream_configure(:commits, dom_id: &"commit-#{&1.sha}")
|
|
|> stream(:commits, [])}
|
|
|
|
nil ->
|
|
raise Ecto.NoResultsError, queryable: Repository
|
|
end
|
|
end
|
|
|
|
@impl true
|
|
def handle_params(params, _uri, socket) do
|
|
{:noreply, apply_action(socket, socket.assigns.live_action, params)}
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("retry", _params, socket) do
|
|
{:noreply,
|
|
socket
|
|
|> assign(:view_state, :loading)
|
|
|> assign(:error_kind, nil)
|
|
|> maybe_load()}
|
|
end
|
|
|
|
def handle_event("load_more", _params, socket) do
|
|
{:noreply,
|
|
socket
|
|
|> assign(:limit, socket.assigns.limit + @page_size)
|
|
|> assign(:view_state, :loading)
|
|
|> maybe_load_commits()}
|
|
end
|
|
|
|
def handle_event("select_branch", %{"branch" => branch}, socket) do
|
|
branch = String.trim(to_string(branch || ""))
|
|
|
|
if socket.assigns.live_action == :index and branch != "" do
|
|
{:noreply,
|
|
socket
|
|
|> assign(:selected_branch, branch)
|
|
|> assign(:limit, @page_size)
|
|
|> assign(:view_state, :loading)
|
|
|> maybe_load_commits()}
|
|
else
|
|
{:noreply, socket}
|
|
end
|
|
end
|
|
|
|
@impl true
|
|
def handle_async(:commits, {:ok, result}, socket) do
|
|
case result do
|
|
{:ok, %{commits: commits}} ->
|
|
{:noreply,
|
|
socket
|
|
|> assign(:view_state, :ready)
|
|
|> assign(:error_kind, nil)
|
|
|> assign(:has_more?, length(commits) >= socket.assigns.limit)
|
|
|> stream(:commits, commits, reset: true)}
|
|
|
|
{:error, reason} ->
|
|
{:noreply,
|
|
socket
|
|
|> assign(:view_state, :error)
|
|
|> assign(:error_kind, reason)
|
|
|> stream(:commits, [], reset: true)}
|
|
end
|
|
end
|
|
|
|
def handle_async(:commits, {:exit, _reason}, socket) do
|
|
{:noreply,
|
|
socket
|
|
|> assign(:view_state, :error)
|
|
|> assign(:error_kind, :unavailable)}
|
|
end
|
|
|
|
def handle_async(:commit, {:ok, result}, socket) do
|
|
case result do
|
|
{:ok, %Commit{} = commit} ->
|
|
{:noreply,
|
|
socket
|
|
|> assign(:view_state, :ready)
|
|
|> assign(:error_kind, nil)
|
|
|> assign(:commit, commit)
|
|
|> assign(
|
|
:page_title,
|
|
"#{commit.subject} · #{socket.assigns.repository.owner}/#{socket.assigns.repository.name}"
|
|
)}
|
|
|
|
{:error, reason} ->
|
|
{:noreply,
|
|
socket
|
|
|> assign(:view_state, :error)
|
|
|> assign(:error_kind, reason)
|
|
|> assign(:commit, nil)}
|
|
end
|
|
end
|
|
|
|
def handle_async(:commit, {:exit, _reason}, socket) do
|
|
{:noreply,
|
|
socket
|
|
|> assign(:view_state, :error)
|
|
|> assign(:error_kind, :unavailable)}
|
|
end
|
|
|
|
@impl true
|
|
def handle_info(
|
|
{:repository_record_updated, %Repository{id: repository_id}},
|
|
%{assigns: %{repository: %Repository{id: repository_id} = repository}} = socket
|
|
) do
|
|
if Repositories.get_visible_repository(
|
|
repository.host,
|
|
repository.owner,
|
|
repository.name,
|
|
socket.assigns.current_scope
|
|
) do
|
|
{:noreply, socket}
|
|
else
|
|
{:noreply, push_navigate(socket, to: "/")}
|
|
end
|
|
end
|
|
|
|
def handle_info({event, %Repository{}}, socket)
|
|
when event in [:repository_registered, :repository_record_updated],
|
|
do: {:noreply, socket}
|
|
|
|
defp apply_action(socket, :index, _params) do
|
|
socket
|
|
|> assign(
|
|
:page_title,
|
|
"Commits · #{socket.assigns.repository.owner}/#{socket.assigns.repository.name}"
|
|
)
|
|
|> assign(:view_state, :loading)
|
|
|> assign(:error_kind, nil)
|
|
|> assign(:commit_sha, nil)
|
|
|> assign(:commit, nil)
|
|
|> maybe_load_commits()
|
|
end
|
|
|
|
defp apply_action(socket, :show, %{"commit_sha" => commit_sha}) do
|
|
if Regex.match?(@commit_sha_pattern, commit_sha) do
|
|
socket
|
|
|> assign(:view_state, :loading)
|
|
|> assign(:error_kind, nil)
|
|
|> assign(:commit_sha, commit_sha)
|
|
|> assign(:commit, nil)
|
|
|> stream(:commits, [], reset: true)
|
|
|> maybe_load_commit()
|
|
else
|
|
socket
|
|
|> assign(:view_state, :error)
|
|
|> assign(:error_kind, :not_found)
|
|
|> assign(:commit_sha, nil)
|
|
|> assign(:commit, nil)
|
|
end
|
|
end
|
|
|
|
defp maybe_load(socket) do
|
|
case socket.assigns.live_action do
|
|
:index -> maybe_load_commits(socket)
|
|
:show -> maybe_load_commit(socket)
|
|
end
|
|
end
|
|
|
|
defp maybe_load_commits(socket) do
|
|
if connected?(socket) do
|
|
repository = socket.assigns.repository
|
|
limit = socket.assigns.limit
|
|
branch = selected_branch(socket)
|
|
|
|
start_async(socket, :commits, fn ->
|
|
RepositoryCode.list_commits(repository, limit, branch: branch)
|
|
end)
|
|
else
|
|
socket
|
|
end
|
|
end
|
|
|
|
defp maybe_load_commit(socket) do
|
|
if connected?(socket) and is_binary(socket.assigns.commit_sha) do
|
|
repository = socket.assigns.repository
|
|
commit_sha = socket.assigns.commit_sha
|
|
|
|
start_async(socket, :commit, fn -> RepositoryCode.show_commit(repository, commit_sha) end)
|
|
else
|
|
socket
|
|
end
|
|
end
|
|
|
|
# The default branch goes through the default-tip resolution; any other
|
|
# picker choice names the branch explicitly.
|
|
defp selected_branch(socket) do
|
|
case socket.assigns.selected_branch do
|
|
branch when is_binary(branch) and branch != "" -> branch
|
|
_other -> nil
|
|
end
|
|
end
|
|
|
|
defp maybe_load_branch_options(socket) do
|
|
if connected?(socket) do
|
|
case RepositoryCode.list_branches(socket.assigns.repository) do
|
|
{:ok, branches} -> assign(socket, :branch_options, branches)
|
|
{:error, _reason} -> socket
|
|
end
|
|
else
|
|
socket
|
|
end
|
|
end
|
|
|
|
defp repository_from_params(params, socket) do
|
|
with {:ok, host, owner, name} <- normalize_params(params),
|
|
%Repository{} = repository <-
|
|
Repositories.get_visible_repository(host, owner, name, socket.assigns.current_scope) do
|
|
repository
|
|
else
|
|
_not_visible -> nil
|
|
end
|
|
end
|
|
|
|
defp normalize_params(%{"host" => slug, "owner" => owner, "name" => name}) do
|
|
with {:ok, host} <- Tarakan.Hosts.host_for_slug(slug) do
|
|
{:ok, host, owner, name}
|
|
end
|
|
end
|
|
|
|
# Bare GitHub-style route (/owner/name/commits...). A host-like first
|
|
# segment means the bare pattern swallowed a remote path - a repository
|
|
# literally named "commits" - so shift the params, same corner as
|
|
# "code"/"security".
|
|
defp normalize_params(%{"owner" => owner, "name" => name}) do
|
|
if Tarakan.Hosts.host_segment?(owner) do
|
|
normalize_params(%{"host" => owner, "owner" => name, "name" => "commits"})
|
|
else
|
|
{:ok, Repository.hosted_host(), owner, name}
|
|
end
|
|
end
|
|
|
|
defp normalize_params(_params), do: :error
|
|
|
|
defp short_sha(sha) when is_binary(sha), do: binary_part(sha, 0, 7)
|
|
|
|
defp commit_datetime(%DateTime{} = datetime),
|
|
do: Calendar.strftime(datetime, "%Y-%m-%d %H:%M UTC")
|
|
|
|
defp commit_datetime(_other), do: "unknown date"
|
|
|
|
defp diff_lines(nil), do: []
|
|
|
|
defp diff_lines(patch) do
|
|
patch
|
|
|> String.split("\n")
|
|
|> Enum.take(@max_diff_lines)
|
|
|> Enum.map(&{diff_line_type(&1), &1})
|
|
end
|
|
|
|
defp diff_line_over_limit?(nil), do: false
|
|
defp diff_line_over_limit?(patch), do: length(String.split(patch, "\n")) > @max_diff_lines
|
|
|
|
defp diff_line_type("+++" <> _rest), do: :meta
|
|
defp diff_line_type("---" <> _rest), do: :meta
|
|
defp diff_line_type("+" <> _rest), do: :add
|
|
defp diff_line_type("-" <> _rest), do: :del
|
|
defp diff_line_type("@@" <> _rest), do: :hunk
|
|
defp diff_line_type(_line), do: :context
|
|
|
|
defp diff_line_class(:add), do: "text-quote"
|
|
defp diff_line_class(:del), do: "text-signal"
|
|
defp diff_line_class(:hunk), do: "text-ink-faint"
|
|
defp diff_line_class(:meta), do: "text-ink-faint"
|
|
defp diff_line_class(:context), do: "text-ink"
|
|
|
|
defp error_title(:empty_repository), do: "No commits yet"
|
|
defp error_title(:not_found), do: "Commit not found"
|
|
defp error_title(_other), do: "History unavailable"
|
|
|
|
defp error_message(:empty_repository),
|
|
do: "Nothing has been pushed to this repository yet."
|
|
|
|
defp error_message(:not_found),
|
|
do: "This commit is not reachable from any published branch."
|
|
|
|
defp error_message(_other),
|
|
do: "The commit history could not be read right now. Try again in a moment."
|
|
end
|