2.7 KiB · text History b868907
defmodule GitGudWeb.RepoLive.EntryTimes do
  @moduledoc """
  Last-touched timestamps for the entries of a tree listing.

  `Repositories.last_touched/4` answers the whole directory in one
  history walk, but that walk still grows with the history behind the
  listing, so the live views kick it off after the connected mount
  (`request/4`) and fold the result in when it lands
  (`handle_result/2`). The listing paints immediately either way.
  """

  use Phoenix.Component

  alias GitGud.Repositories

  @timeout 30_000

  @doc "Kick off the walk for `entries` under `base_path`, if connected."
  def request(socket, repo, head_sha, base_path, entries) do
    if Phoenix.LiveView.connected?(socket) and entries != [] do
      parent = self()
      names = Enum.map(entries, & &1.name)

      Task.Supervisor.start_child(GitGud.TaskSupervisor, fn ->
        send(parent, {__MODULE__, head_sha, load(repo, head_sha, base_path, names)})
      end)
    end

    Phoenix.Component.assign(socket, :entry_times, %{})
  end

  @doc """
  Fold a result in, ignoring one that belongs to a commit the view has
  since navigated away from.
  """
  def handle_result(socket, {__MODULE__, head_sha, times}) do
    if socket.assigns[:head_sha] == head_sha do
      Phoenix.Component.assign(socket, :entry_times, times)
    else
      socket
    end
  end

  # The walk runs in a task of its own purely so a wedged backend call
  # can't pin this process forever — a listing with no times beats one
  # that never arrives.
  defp load(repo, head_sha, base_path, names) do
    task =
      Task.Supervisor.async_nolink(GitGud.TaskSupervisor, fn ->
        Repositories.last_touched(repo, head_sha, base_path, names)
      end)

    case Task.yield(task, @timeout) || Task.shutdown(task, :brutal_kill) do
      {:ok, {:ok, touches}} ->
        Map.new(touches, fn {name, %{committed_at: at}} -> {name, at} end)

      _ ->
        %{}
    end
  end

  @doc "The cell rendered in the listing's last-touched column."
  attr :times, :map, required: true
  attr :name, :string, required: true

  def entry_time(assigns) do
    assigns = assign(assigns, :at, Map.get(assigns.times, assigns.name))

    ~H"""
    <span :if={@at} title={DateTime.to_iso8601(@at)} class="whitespace-nowrap">{ago(@at)}</span>
    """
  end

  @doc "Coarse relative age: `3d`, `2w`, `5mo`, `1y`."
  def ago(%DateTime{} = dt) do
    case DateTime.diff(DateTime.utc_now(), dt) do
      s when s < 60 -> "just now"
      s when s < 3600 -> "#{div(s, 60)}m ago"
      s when s < 86_400 -> "#{div(s, 3600)}h ago"
      s when s < 604_800 -> "#{div(s, 86_400)}d ago"
      s when s < 2_592_000 -> "#{div(s, 604_800)}w ago"
      s when s < 31_536_000 -> "#{div(s, 2_592_000)}mo ago"
      s -> "#{div(s, 31_536_000)}y ago"
    end
  end
end