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