defmodule GitGudWeb.RepoLive.Show do
use GitGudWeb, :live_view
alias GitGud.Git
alias GitGud.Repositories
alias GitGud.Repositories.Storage
@impl true
def mount(%{"owner" => owner, "name" => name} = params, _session, socket) do
repo =
Repositories.get_repository_by_path!(owner, name)
|> GitGud.Repo.preload([:owner, :organization])
ref = params["ref"] || repo.default_branch
socket =
socket
|> assign(:repo, repo)
|> assign(:handle, Storage.repo_handle(repo))
|> assign(:current_ref, ref)
|> assign(:transport, "ssh")
|> assign(:clone_widget_state, 0)
|> GitGudWeb.RepoLive.Header.assign_chrome(repo)
|> assign(:page_title, "#{owner}/#{name}")
|> load_overview(ref)
{:ok, socket}
end
defp load_overview(socket, ref) do
repo = socket.assigns.repo
case Repositories.resolve(repo, ref) do
{:ok, sha} ->
{:ok, refs} = Repositories.list_refs(repo)
{:ok, head_commit} = Repositories.get_commit(repo, sha)
{:ok, entries} = Repositories.get_tree(repo, head_commit.tree_sha)
readme = find_and_render_readme(repo, entries, socket.assigns[:editor_theme])
socket
|> assign(:empty?, false)
|> assign(:head_sha, sha)
|> assign(:head_commit, head_commit)
|> assign(:branches, Enum.filter(refs, &(&1.ref_type == "branch")))
|> assign(:tags, Enum.filter(refs, &(&1.ref_type == "tag")))
|> assign(:entries, entries)
|> assign(:readme, readme)
|> assign(:fork_status, maybe_fork_status(repo, ref))
|> assign(:language_stats, load_language_stats(repo, sha))
{:error, _} ->
socket
|> assign(:empty?, true)
|> assign(:head_sha, nil)
|> assign(:head_commit, nil)
|> assign(:branches, [])
|> assign(:tags, [])
|> assign(:entries, [])
|> assign(:readme, nil)
|> assign(:fork_status, nil)
|> assign(:language_stats, nil)
end
end
defp load_language_stats(repo, sha) do
case Repositories.get_language_stats(repo, sha) do
{:ok, %{languages: []}} ->
nil
{:ok, stats} ->
stats
:not_computed ->
_ =
GitGud.Repositories.Workers.LanguageStats.new_job(%{
repository_id: repo.id,
sha: sha
})
|> Oban.insert()
nil
end
end
defp maybe_fork_status(%{parent_repository_id: nil}, _ref), do: nil
defp maybe_fork_status(repo, ref) do
branch = String.replace_prefix(ref, "refs/heads/", "")
case Repositories.fork_status(repo, branch) do
{:ok, status} -> Map.put(status, :branch, branch)
_ -> nil
end
end
defp find_and_render_readme(repo, entries, theme) do
case Enum.find(entries, fn e ->
e.type == "blob" and Regex.match?(~r/^readme(\.md)?$/i, e.name)
end) do
nil ->
nil
%{sha: sha, name: name} ->
case Repositories.get_blob_bytes(repo, sha) do
{:ok, bytes} ->
cond do
String.ends_with?(String.downcase(name), ".md") ->
{:markdown, render_markdown(bytes, theme)}
true ->
{:plain, bytes}
end
_ ->
nil
end
end
end
defp render_markdown(bytes, theme), do: GitGud.Markdown.to_html(bytes, theme: theme)
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash} current_scope={@current_scope}>
<div class="space-y-6">
<GitGudWeb.RepoLive.Header.header
repo={@repo}
handle={@handle}
current_scope={@current_scope}
has_packages?={@has_packages?}
can_admin?={@can_admin?}
latest_run={@latest_run}
/>
<div
:if={@fork_status && (@fork_status.behind > 0 or @fork_status.ahead > 0)}
class="rounded-box border border-base-300 bg-base-200 p-3 text-sm flex items-center gap-3"
>
<.icon name="hero-arrow-down-tray" class="size-4 text-primary" />
<div class="flex-1">
<p>
This fork is
<span :if={@fork_status.behind > 0}>
<strong>{@fork_status.behind}</strong> commit(s) behind upstream
</span>
<span :if={@fork_status.behind > 0 and @fork_status.ahead > 0}> and </span>
<span :if={@fork_status.ahead > 0}>
<strong>{@fork_status.ahead}</strong> commit(s) ahead
</span>
on <code class="font-mono">{@fork_status.branch}</code>.
</p>
<p :if={@fork_status.ahead > 0 and @fork_status.behind > 0} class="text-xs opacity-60">
Diverged — sync isn't a fast-forward; resolve via PR or manual merge.
</p>
</div>
<button
:if={@fork_status.fast_forward?}
phx-click="sync_fork"
class="btn btn-xs btn-primary"
data-confirm={"Fast-forward this branch by #{@fork_status.behind} commit(s) from upstream?"}
>
Sync from upstream
</button>
</div>
<%= if @empty? do %>
<.empty_repo_onboarding
repo={@repo}
handle={@handle}
transport={@transport}
http_url={http_clone_url(@repo, @handle)}
ssh_url={ssh_clone_url(@repo, @handle, @current_scope)}
current_scope={@current_scope}
/>
<% else %>
<section class="flex items-center gap-2 text-sm">
<select
id="ref-picker"
class="select select-sm select-bordered"
phx-change="switch_ref"
name="ref"
>
<optgroup label="Branches">
<option
:for={b <- @branches}
value={short_ref(b.name)}
selected={short_ref(b.name) == @current_ref}
>
{short_ref(b.name)}
</option>
</optgroup>
<optgroup :if={@tags != []} label="Tags">
<option
:for={t <- @tags}
value={short_ref(t.name)}
selected={short_ref(t.name) == @current_ref}
>
{short_ref(t.name)}
</option>
</optgroup>
</select>
<.link
navigate={~p"/r/#{@handle}/#{@repo.name}/commits/#{@current_ref}"}
class="btn btn-sm btn-ghost"
>
<.icon name="hero-clock" class="size-4" /> Commits
</.link>
<.clone_widget
state={@clone_widget_state}
transport={@transport}
http_url={http_clone_url(@repo, @handle)}
ssh_url={ssh_clone_url(@repo, @handle, @current_scope)}
/>
<span class="ml-auto text-xs opacity-60 font-mono">
{Git.short(@head_sha)}
</span>
</section>
<.language_bar :if={@language_stats} stats={@language_stats} handle={@handle} repo={@repo} />
<section>
<table class="table table-sm">
<tbody>
<tr :for={entry <- sorted(@entries)} id={"entry-#{entry.name}"}>
<td class="w-8">
<.icon name={icon_for(entry)} class="size-4 opacity-70" />
</td>
<td class="font-mono">
<.link
navigate={tree_link(@handle, @repo, @current_ref, entry)}
class="link link-hover"
>
{entry.name}
</.link>
</td>
<td class="text-xs opacity-60">{format_size(entry.size)}</td>
</tr>
</tbody>
</table>
</section>
<section :if={@readme} class="prose max-w-none border-t border-base-300 pt-6">
<%= case @readme do %>
<% {:markdown, html} -> %>
{Phoenix.HTML.raw(html)}
<% {:plain, plain_bytes} -> %>
<pre class="text-xs" phx-no-curly-interpolation><%= plain_bytes %></pre>
<% end %>
</section>
<% end %>
</div>
</Layouts.app>
"""
end
@impl true
def handle_event("set_transport", %{"transport" => t}, socket)
when t in ["ssh", "http"] do
{:noreply, assign(socket, :transport, t)}
end
def handle_event("cycle_clone_widget", _params, socket) do
next = rem(socket.assigns.clone_widget_state + 1, 3)
{:noreply, assign(socket, :clone_widget_state, next)}
end
def handle_event("switch_ref", %{"ref" => ref}, socket) do
{:noreply,
push_patch(socket,
to: ~p"/r/#{socket.assigns.handle}/#{socket.assigns.repo.name}?ref=#{ref}"
)}
end
def handle_event("sync_fork", _params, socket) do
case socket.assigns.current_scope && socket.assigns.current_scope.user do
nil ->
{:noreply, put_flash(socket, :error, "Sign in to sync forks.")}
user ->
branch = String.replace_prefix(socket.assigns.current_ref, "refs/heads/", "")
case Repositories.sync_fork(socket.assigns.repo, branch, user) do
{:ok, %{behind: behind}} ->
{:noreply,
socket
|> put_flash(:info, "Synced #{behind} commit(s) from upstream.")
|> load_overview(socket.assigns.current_ref)}
{:error, :already_up_to_date} ->
{:noreply, put_flash(socket, :info, "Already up to date.")}
{:error, {:diverged, _}} ->
{:noreply,
put_flash(
socket,
:error,
"Cannot sync: this fork has commits the upstream doesn't. Resolve manually."
)}
{:error, :forbidden} ->
{:noreply, put_flash(socket, :error, "Only the fork's owner can sync.")}
{:error, reason} ->
{:noreply, put_flash(socket, :error, "Sync failed: #{inspect(reason)}")}
end
end
end
@impl true
def handle_params(%{"ref" => ref}, _uri, socket) do
{:noreply, socket |> assign(:current_ref, ref) |> load_overview(ref)}
end
def handle_params(_, _, socket), do: {:noreply, socket}
defp sorted(entries) do
Enum.sort_by(entries, fn e -> {e.type != "tree", String.downcase(e.name)} end)
end
defp icon_for(%{type: "tree"}), do: "hero-folder"
defp icon_for(%{type: "blob"}), do: "hero-document"
defp icon_for(_), do: "hero-question-mark-circle"
defp tree_link(handle, repo, ref, %{type: "tree", name: name}),
do: ~p"/r/#{handle}/#{repo.name}/tree/#{ref}/#{name}"
defp tree_link(handle, repo, ref, %{type: "blob", name: name}),
do: ~p"/r/#{handle}/#{repo.name}/blob/#{ref}/#{name}"
defp tree_link(_, _, _, _), do: "#"
defp short_ref("refs/heads/" <> n), do: n
defp short_ref("refs/tags/" <> n), do: n
defp short_ref(n), do: n
defp format_size(nil), do: ""
defp format_size(b) when b < 1024, do: "#{b} B"
defp format_size(b) when b < 1_000_000, do: "#{Float.round(b / 1024, 1)} KiB"
defp format_size(b), do: "#{Float.round(b / 1_048_576, 1)} MiB"
# ── clone widget ───────────────────────────────────────────────────
attr :state, :integer, required: true, doc: "0=collapsed · 1=+copy · 2=+url+copy"
attr :transport, :string, required: true
attr :http_url, :string, required: true
attr :ssh_url, :string, required: true
defp clone_widget(assigns) do
active_url = if assigns.transport == "ssh", do: assigns.ssh_url, else: assigns.http_url
assigns = assign(assigns, :active_url, active_url)
~H"""
<div role="group" class="join">
<button
type="button"
phx-click="set_transport"
phx-value-transport="http"
class={["btn btn-sm join-item", @transport == "http" && "btn-primary"]}
>
HTTP
</button>
<button
type="button"
phx-click="set_transport"
phx-value-transport="ssh"
class={["btn btn-sm join-item", @transport == "ssh" && "btn-primary"]}
>
SSH
</button>
<span
:if={@state >= 2}
class="join-item bg-base-200 px-2 text-xs font-mono flex items-center select-all max-w-xs truncate"
title={@active_url}
>
{@active_url}
</span>
<button
:if={@state >= 1}
type="button"
phx-click={
Phoenix.LiveView.JS.dispatch("git_gud:copy", detail: %{text: @active_url})
|> Phoenix.LiveView.JS.transition("scale-110", time: 150)
}
class="btn btn-sm join-item"
title={"Copy " <> @active_url}
aria-label="Copy clone URL"
>
<.icon name="hero-clipboard-document" class="size-4" />
</button>
<button
type="button"
phx-click="cycle_clone_widget"
class="btn btn-sm join-item"
title={if @state >= 2, do: "Collapse", else: "Show URL / copy"}
aria-label="Toggle clone URL widget"
>
<.icon
name={if @state >= 2, do: "hero-chevron-left", else: "hero-chevron-right"}
class="size-4"
/>
</button>
</div>
"""
end
# ── language bar ───────────────────────────────────────────────────
attr :stats, :map, required: true
attr :handle, :string, required: true
attr :repo, :map, required: true
defp language_bar(assigns) do
segments = top_segments(assigns.stats)
assigns = assign(assigns, :segments, segments)
~H"""
<.link
navigate={~p"/r/#{@handle}/#{@repo.name}/stats/languages"}
class="block group"
title={language_summary(@stats)}
>
<div class="flex w-full overflow-hidden rounded-full bg-base-300" style="height: 0.3em;">
<div
:for={s <- @segments}
class="h-full"
style={"width: #{s.percent}%; background-color: #{s.color};"}
title={"#{s.language} · #{s.percent}%"}
/>
</div>
</.link>
"""
end
@top_n 6
defp top_segments(%{languages: languages, total_bytes: total}) when total > 0 do
{top, rest} = Enum.split(languages, @top_n)
top_with_pct =
Enum.map(top, fn s ->
%{
language: s.language,
color: GitGud.LanguageColors.for(s.language),
percent: pct(s.bytes, total)
}
end)
case rest do
[] ->
top_with_pct
_ ->
other_bytes = Enum.reduce(rest, 0, &(&1.bytes + &2))
top_with_pct ++
[%{language: "Other", color: "#888888", percent: pct(other_bytes, total)}]
end
end
defp top_segments(_), do: []
defp pct(_, 0), do: 0.0
defp pct(bytes, total), do: Float.round(bytes * 100 / total, 2)
defp language_summary(%{languages: languages, total_bytes: total}) when total > 0 do
languages
|> Enum.take(@top_n)
|> Enum.map(fn s -> "#{s.language} #{pct(s.bytes, total)}%" end)
|> Enum.join(" · ")
end
defp language_summary(_), do: ""
# ── empty-state onboarding ─────────────────────────────────────────
attr :repo, :map, required: true
attr :handle, :string, required: true
attr :transport, :string, required: true
attr :http_url, :string, required: true
attr :ssh_url, :string, required: true
attr :current_scope, :map, default: nil
defp empty_repo_onboarding(assigns) do
assigns =
assign(
assigns,
:url,
if(assigns.transport == "ssh", do: assigns.ssh_url, else: assigns.http_url)
)
~H"""
<div class="rounded-box border border-base-300 bg-base-200 p-6 space-y-4">
<div class="space-y-1">
<h2 class="font-semibold text-lg">This repository is empty.</h2>
<p class="text-sm opacity-70">
Set up <code class="font-mono">{@handle}/{@repo.name}</code>
locally and push your first commit.
</p>
</div>
<div class="flex items-center gap-2 text-xs">
<span class="opacity-70">Transport:</span>
<div role="group" class="join">
<button
type="button"
phx-click="set_transport"
phx-value-transport="ssh"
class={["btn btn-xs join-item", @transport == "ssh" && "btn-primary"]}
>
SSH
</button>
<button
type="button"
phx-click="set_transport"
phx-value-transport="http"
class={["btn btn-xs join-item", @transport == "http" && "btn-primary"]}
>
HTTPS
</button>
</div>
<span
:if={@transport == "ssh" and !ssh_keys_configured?(@current_scope)}
class="ml-2 text-warning text-xs"
>
<.icon name="hero-exclamation-triangle-micro" class="size-3" /> No SSH keys —
<.link navigate={~p"/users/settings/ssh-keys"} class="underline">
add one
</.link>
</span>
</div>
<details class="space-y-2" open>
<summary class="font-medium text-sm cursor-pointer select-none">
Brand new local repository
</summary>
<pre class="text-xs bg-base-300 p-3 rounded mt-2 overflow-x-auto" phx-no-curly-interpolation><%= "echo \"# " <> @repo.name <> "\" > README.md\ngit init\ngit add README.md\ngit commit -m \"first\"\ngit branch -M " <> (@repo.default_branch || "main") <> "\ngit remote add origin " <> @url <> "\ngit push -u origin " <> (@repo.default_branch || "main") %></pre>
</details>
<details class="space-y-2">
<summary class="font-medium text-sm cursor-pointer select-none">
Push an existing local repository
</summary>
<pre class="text-xs bg-base-300 p-3 rounded mt-2 overflow-x-auto" phx-no-curly-interpolation><%= "git remote add origin " <> @url <> "\ngit branch -M " <> (@repo.default_branch || "main") <> "\ngit push -u origin " <> (@repo.default_branch || "main") %></pre>
</details>
<details class="space-y-2">
<summary class="font-medium text-sm cursor-pointer select-none">
Clone this repository
</summary>
<pre class="text-xs bg-base-300 p-3 rounded mt-2 overflow-x-auto" phx-no-curly-interpolation><%= "git clone " <> @url %></pre>
</details>
<div class="text-xs opacity-60">
URL: <code class="font-mono">{@url}</code>
</div>
</div>
"""
end
defp ssh_keys_configured?(%{user: %{id: uid}}) do
GitGud.SshKeys.list_keys(%GitGud.Accounts.User{id: uid}) != []
end
defp ssh_keys_configured?(_), do: false
# ── clone URLs ─────────────────────────────────────────────────────
defp http_clone_url(repo, handle) do
GitGudWeb.Endpoint.url() <> "/" <> handle <> "/" <> repo.name <> ".git"
end
defp ssh_clone_url(repo, owner_handle, current_scope) do
ssh_cfg = Application.get_env(:git_gud, GitGud.Ssh.Server, [])
host =
Keyword.get(ssh_cfg, :external_host) ||
URI.parse(GitGudWeb.Endpoint.url()).host ||
"localhost"
# Prefer the explicit external port (what clients hit through the
# LB / ingress); fall back to the internal listener port. The two
# match for "expose the daemon directly" deploys and diverge when
# something like Traefik maps a public 22222 → internal 2222.
port =
Keyword.get(ssh_cfg, :external_port) ||
Keyword.get(ssh_cfg, :port, 2222)
# The SSH `<user>` part is whoever is *connecting* — i.e., the
# viewer's handle, validated by their key in /users/settings/ssh-
# keys. It is NOT the repo owner's handle (those are independent
# variables). Fall back to a `<your-handle>` placeholder when the
# viewer is anonymous so it's obvious what to substitute.
user =
case current_scope do
%{user: %{handle: h}} when is_binary(h) and h != "" -> h
_ -> "<your-handle>"
end
"ssh://#{user}@#{host}:#{port}/#{owner_handle}/#{repo.name}.git"
end
end
20.0 KiB · text
5af55d9