neiam /gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Packages
⭐
Log in to mark this repository.
Page through a file's versions from the blob view
01eb20a · Gabriel Morell · 2026-09-09 20:24
Message
{commit_body(@commit)}
Files changed
modified
lib/git_gud_web/live/repo_live/blob.ex
+158
−12
@@ -7,8 +7,16 @@ defmodule GitGudWeb.RepoLive.Blob do
| 7 | 7 | |
| 8 | 8 | @max_inline_bytes 1_000_000 |
| 9 | 9 | |
| 10 | + # How far back the version pager will look. Beyond this the History | |
| 11 | + # page is the way to reach older revisions. | |
| 12 | + @version_limit 100 | |
| 13 | + | |
| 10 | 14 | @impl true |
| 11 | − def mount(%{"owner" => owner, "name" => name, "ref" => ref, "path" => path}, _session, socket) do | |
| 15 | + def mount( | |
| 16 | + %{"owner" => owner, "name" => name, "ref" => ref, "path" => path} = params, | |
| 17 | + _session, | |
| 18 | + socket | |
| 19 | + ) do | |
| 12 | 20 | repo = |
| 13 | 21 | Repositories.get_repository_by_path!(owner, name) |
| 14 | 22 | |> GitGud.Repo.preload([:owner, :organization]) |
@@ -17,6 +25,26 @@ defmodule GitGudWeb.RepoLive.Blob do
| 17 | 25 | path = Enum.join(path_segs, "/") |
| 18 | 26 | |
| 19 | 27 | {:ok, head_sha} = Repositories.resolve(repo, ref) |
| 28 | + | |
| 29 | + # `from` names the branch the history is read against. Without it a | |
| 30 | + # commit SHA in the URL would only tell us what came *before* this | |
| 31 | + # version, never what came after. | |
| 32 | + {from_ref, versions} = load_versions(repo, params["from"], path) | |
| 33 | + idx = version_index(versions, head_sha, ref) | |
| 34 | + | |
| 35 | + socket = | |
| 36 | + socket | |
| 37 | + |> assign(:repo, repo) | |
| 38 | + |> assign(:handle, Storage.repo_handle(repo)) | |
| 39 | + |> GitGudWeb.RepoLive.Header.assign_chrome(repo) | |
| 40 | + |> assign(:ref, ref) | |
| 41 | + |> assign(:path, path) | |
| 42 | + |> assign(:path_segs, path_segs) | |
| 43 | + |> assign(:head_sha, head_sha) | |
| 44 | + |> assign(:from_ref, from_ref) | |
| 45 | + |> assign_versions(versions, idx) | |
| 46 | + |> assign(:page_title, "#{owner}/#{name}: #{path}") | |
| 47 | + | |
| 20 | 48 | {:ok, commit} = Repositories.get_commit(repo, head_sha) |
| 21 | 49 | |
| 22 | 50 | case Repositories.get_tree_entry_at(repo, commit.tree_sha, path) do |
@@ -32,23 +60,91 @@ defmodule GitGudWeb.RepoLive.Blob do
| 32 | 60 | |
| 33 | 61 | {:ok, |
| 34 | 62 | socket |
| 35 | − |> assign(:repo, repo) | |
| 36 | − |> assign(:handle, Storage.repo_handle(repo)) | |
| 37 | − |> GitGudWeb.RepoLive.Header.assign_chrome(repo) | |
| 38 | − |> assign(:ref, ref) | |
| 39 | − |> assign(:path, path) | |
| 40 | − |> assign(:path_segs, path_segs) | |
| 41 | 63 | |> assign(:blob_name, bname) |
| 42 | 64 | |> assign(:meta, meta) |
| 43 | − |> assign(:body, body) | |
| 44 | − |> assign(:head_sha, head_sha) | |
| 45 | − |> assign(:page_title, "#{owner}/#{name}: #{path}")} | |
| 65 | + |> assign(:body, body)} | |
| 66 | + | |
| 67 | + _ when not is_nil(idx) -> | |
| 68 | + # The commit that deleted the file is part of its history, so | |
| 69 | + # landing here is expected. Keep the pager rather than bouncing | |
| 70 | + # the user out to the repo root. | |
| 71 | + {:ok, | |
| 72 | + socket | |
| 73 | + |> assign(:blob_name, Path.basename(path)) | |
| 74 | + |> assign(:meta, nil) | |
| 75 | + |> assign(:body, :absent)} | |
| 46 | 76 | |
| 47 | 77 | _ -> |
| 48 | 78 | {:ok, push_navigate(socket, to: ~p"/r/#{owner}/#{name}")} |
| 49 | 79 | end |
| 50 | 80 | end |
| 51 | 81 | |
| 82 | + # Newest first, so index 0 is the current state of the file. | |
| 83 | + defp load_versions(repo, from, path) do | |
| 84 | + from_ref = from || repo.default_branch | |
| 85 | + | |
| 86 | + with true <- is_binary(from_ref) and from_ref != "", | |
| 87 | + {:ok, sha} <- Repositories.resolve(repo, from_ref), | |
| 88 | + {:ok, commits} <- | |
| 89 | + Repositories.list_commits(repo, sha, limit: @version_limit, paths: [path]) do | |
| 90 | + {from_ref, Enum.map(commits, &decorate/1)} | |
| 91 | + else | |
| 92 | + _ -> {from_ref, []} | |
| 93 | + end | |
| 94 | + end | |
| 95 | + | |
| 96 | + defp decorate(commit) do | |
| 97 | + Map.merge(commit, %{ | |
| 98 | + id: Git.to_hex(commit.sha), | |
| 99 | + short: Git.short(commit.sha), | |
| 100 | + title: commit.message |> String.split("\n", parts: 2) |> hd() | |
| 101 | + }) | |
| 102 | + end | |
| 103 | + | |
| 104 | + defp version_index([], _head_sha, _ref), do: nil | |
| 105 | + | |
| 106 | + defp version_index(versions, head_sha, ref) do | |
| 107 | + case Enum.find_index(versions, &(&1.sha == head_sha)) do | |
| 108 | + # A branch or tag shows the tip, whose content is whatever the | |
| 109 | + # newest commit touching this file produced — position 0 — even | |
| 110 | + # though the tip itself needn't appear in the file's history. A | |
| 111 | + # SHA we can't place (another branch, or past the limit) gets no | |
| 112 | + # pager at all. | |
| 113 | + nil -> if sha_ref?(ref), do: nil, else: 0 | |
| 114 | + i -> i | |
| 115 | + end | |
| 116 | + end | |
| 117 | + | |
| 118 | + defp sha_ref?(ref), | |
| 119 | + do: byte_size(ref) >= 7 and String.match?(ref, ~r/\A[0-9a-fA-F]+\z/) | |
| 120 | + | |
| 121 | + defp assign_versions(socket, versions, nil) do | |
| 122 | + socket | |
| 123 | + |> assign(:versions, versions) | |
| 124 | + |> assign(:version_idx, nil) | |
| 125 | + |> assign(:current_version, nil) | |
| 126 | + |> assign(:older_version, nil) | |
| 127 | + |> assign(:newer_version, nil) | |
| 128 | + end | |
| 129 | + | |
| 130 | + defp assign_versions(socket, versions, idx) do | |
| 131 | + socket | |
| 132 | + |> assign(:versions, versions) | |
| 133 | + |> assign(:version_idx, idx) | |
| 134 | + |> assign(:current_version, Enum.at(versions, idx)) | |
| 135 | + |> assign(:older_version, Enum.at(versions, idx + 1)) | |
| 136 | + |> assign(:newer_version, idx > 0 && Enum.at(versions, idx - 1)) | |
| 137 | + end | |
| 138 | + | |
| 139 | + # A full page of results probably means there are more behind it. | |
| 140 | + defp version_count(versions) when length(versions) >= @version_limit, | |
| 141 | + do: "#{@version_limit}+" | |
| 142 | + | |
| 143 | + defp version_count(versions), do: length(versions) | |
| 144 | + | |
| 145 | + defp version_path(handle, repo, segs, sha, from), | |
| 146 | + do: ~p"/r/#{handle}/#{repo.name}/blob/#{sha}/#{segs}?#{[from: from]}" | |
| 147 | + | |
| 52 | 148 | defp load_text(repo, sha, name, theme) do |
| 53 | 149 | case Repositories.get_blob_bytes(repo, sha) do |
| 54 | 150 | {:ok, bytes} -> |
@@ -90,10 +186,13 @@ defmodule GitGudWeb.RepoLive.Blob do
| 90 | 186 | </nav> |
| 91 | 187 | |
| 92 | 188 | <div class="flex items-center justify-between text-xs opacity-70"> |
| 93 | − <span>{format_size(@meta.size)} · {if @meta.is_binary, do: "binary", else: "text"}</span> | |
| 189 | + <span :if={@meta}> | |
| 190 | + {format_size(@meta.size)} · {if @meta.is_binary, do: "binary", else: "text"} | |
| 191 | + </span> | |
| 192 | + <span :if={!@meta}>not present at this version</span> | |
| 94 | 193 | <span class="flex items-center gap-3"> |
| 95 | 194 | <.link |
| 96 | − navigate={~p"/r/#{@handle}/#{@repo.name}/commits/#{@ref}/#{@path_segs}"} | |
| 195 | + navigate={~p"/r/#{@handle}/#{@repo.name}/commits/#{@from_ref}/#{@path_segs}"} | |
| 97 | 196 | class="link link-hover" |
| 98 | 197 | > |
| 99 | 198 | History |
@@ -102,6 +201,45 @@ defmodule GitGudWeb.RepoLive.Blob do
| 102 | 201 | </span> |
| 103 | 202 | </div> |
| 104 | 203 | |
| 204 | + <div | |
| 205 | + :if={@current_version} | |
| 206 | + class="flex items-center justify-between gap-3 border border-base-300 rounded-md px-3 py-2" | |
| 207 | + > | |
| 208 | + <.link | |
| 209 | + :if={@older_version} | |
| 210 | + navigate={version_path(@handle, @repo, @path_segs, @older_version.id, @from_ref)} | |
| 211 | + class="btn btn-xs" | |
| 212 | + > | |
| 213 | + ← Older | |
| 214 | + </.link> | |
| 215 | + <span :if={!@older_version} class="btn btn-xs btn-disabled">← Older</span> | |
| 216 | + | |
| 217 | + <div class="text-center min-w-0"> | |
| 218 | + <p class="text-xs truncate"> | |
| 219 | + <.link | |
| 220 | + navigate={~p"/r/#{@handle}/#{@repo.name}/commit/#{@current_version.id}"} | |
| 221 | + class="link link-hover" | |
| 222 | + > | |
| 223 | + {@current_version.title} | |
| 224 | + </.link> | |
| 225 | + </p> | |
| 226 | + <p class="text-xs opacity-60"> | |
| 227 | + version {@version_idx + 1} of {version_count(@versions)} · {@current_version.author_name} · {format_dt( | |
| 228 | + @current_version.committed_at | |
| 229 | + )} | |
| 230 | + </p> | |
| 231 | + </div> | |
| 232 | + | |
| 233 | + <.link | |
| 234 | + :if={@newer_version} | |
| 235 | + navigate={version_path(@handle, @repo, @path_segs, @newer_version.id, @from_ref)} | |
| 236 | + class="btn btn-xs" | |
| 237 | + > | |
| 238 | + Newer → | |
| 239 | + </.link> | |
| 240 | + <span :if={!@newer_version} class="btn btn-xs btn-disabled">Newer →</span> | |
| 241 | + </div> | |
| 242 | + | |
| 105 | 243 | <article class="border border-base-300 rounded-md overflow-hidden"> |
| 106 | 244 | <%= case @body do %> |
| 107 | 245 | <% {:markdown, _src, html} -> %> |
@@ -116,6 +254,11 @@ defmodule GitGudWeb.RepoLive.Blob do
| 116 | 254 | <p class="p-6 text-sm opacity-70">Binary file. Use <code>git</code> to view.</p> |
| 117 | 255 | <% {:too_large, _size} -> %> |
| 118 | 256 | <p class="p-6 text-sm opacity-70">File too large to render in-browser.</p> |
| 257 | + <% :absent -> %> | |
| 258 | + <p class="p-6 text-sm opacity-70"> | |
| 259 | + This file doesn't exist at this version — it was added later, or | |
| 260 | + this is the commit that removed it. | |
| 261 | + </p> | |
| 119 | 262 | <% {:error, msg} -> %> |
| 120 | 263 | <p class="p-6 text-sm text-error">Error: {msg}</p> |
| 121 | 264 | <% end %> |
@@ -125,6 +268,9 @@ defmodule GitGudWeb.RepoLive.Blob do
| 125 | 268 | """ |
| 126 | 269 | end |
| 127 | 270 | |
| 271 | + defp format_dt(nil), do: "?" | |
| 272 | + defp format_dt(dt), do: Calendar.strftime(dt, "%Y-%m-%d") | |
| 273 | + | |
| 128 | 274 | defp format_size(b) when b < 1024, do: "#{b} B" |
| 129 | 275 | defp format_size(b) when b < 1_000_000, do: "#{Float.round(b / 1024, 1)} KiB" |
| 130 | 276 | defp format_size(b), do: "#{Float.round(b / 1_048_576, 1)} MiB" |
modified
lib/git_gud_web/live/repo_live/log.ex
+12
−1
@@ -118,7 +118,18 @@ defmodule GitGudWeb.RepoLive.Log do
| 118 | 118 | > |
| 119 | 119 | {c.title} |
| 120 | 120 | </.link> |
| 121 | − <span class="text-xs font-mono opacity-60">{c.short}</span> | |
| 121 | + <%!-- On a path-scoped history the hash opens the file as it | |
| 122 | + stood at that commit; otherwise it opens the commit. --%> | |
| 123 | + <.link | |
| 124 | + navigate={ | |
| 125 | + if @path != "", | |
| 126 | + do: ~p"/r/#{@handle}/#{@repo.name}/blob/#{c.id}/#{@path_segs}?#{[from: @ref]}", | |
| 127 | + else: ~p"/r/#{@handle}/#{@repo.name}/commit/#{c.id}" | |
| 128 | + } | |
| 129 | + class="text-xs font-mono opacity-60 link link-hover shrink-0" | |
| 130 | + > | |
| 131 | + {c.short} | |
| 132 | + </.link> | |
| 122 | 133 | </div> |
| 123 | 134 | <p class="text-xs opacity-60"> |
| 124 | 135 | {c.author_name} · {format_dt(c.committed_at)} |
modified
test/git_gud_web/live/repo_live/log_path_test.exs
+69
−3
@@ -11,13 +11,17 @@ defmodule GitGudWeb.RepoLive.LogPathTest do
| 11 | 11 | |
| 12 | 12 | # Three commits in the bare repo: one touching only a.txt, one |
| 13 | 13 | # touching only b.txt, one touching a.txt again. |
| 14 | − defp seed_history(repo) do | |
| 14 | + defp seed_history(repo, steps \\ [{"a.txt", "1"}, {"b.txt", "1"}, {"a.txt", "2"}]) do | |
| 15 | 15 | path = repo.disk_path |
| 16 | 16 | |
| 17 | 17 | {sha, _files} = |
| 18 | − Enum.reduce([{"a.txt", "1"}, {"b.txt", "1"}, {"a.txt", "2"}], {nil, %{}}, fn | |
| 18 | + Enum.reduce(steps, {nil, %{}}, fn | |
| 19 | 19 | {file, body}, {parent, files} -> |
| 20 | − files = Map.put(files, file, write_blob(path, body)) | |
| 20 | + # A nil body removes the file, so history can cover deletions. | |
| 21 | + files = | |
| 22 | + if body, | |
| 23 | + do: Map.put(files, file, write_blob(path, body)), | |
| 24 | + else: Map.delete(files, file) | |
| 21 | 25 | |
| 22 | 26 | entries = |
| 23 | 27 | Enum.map_join(files, "", fn {name, blob} -> "100644 blob #{blob}\t#{name}\n" end) |
@@ -32,6 +36,21 @@ defmodule GitGudWeb.RepoLive.LogPathTest do
| 32 | 36 | :ok = Git.update_ref(path, "refs/heads/" <> repo.default_branch, sha, nil) |
| 33 | 37 | end |
| 34 | 38 | |
| 39 | + test "a version where the file was deleted still pages", %{conn: conn, user: user} do | |
| 40 | + {_user, repo} = repository_fixture(%{owner: user, visibility: "public"}) | |
| 41 | + seed_history(repo, [{"a.txt", "1"}, {"keep.txt", "x"}, {"a.txt", nil}]) | |
| 42 | + repo = GitGud.Repo.preload(repo, [:owner, :organization]) | |
| 43 | + base = "/r/#{Storage.repo_handle(repo)}/#{repo.name}" | |
| 44 | + | |
| 45 | + [deleted_at, added_at] = versions_of(repo, "a.txt") | |
| 46 | + | |
| 47 | + {:ok, _lv, html} = live(conn, "#{base}/blob/#{deleted_at}/a.txt?from=#{repo.default_branch}") | |
| 48 | + assert html =~ "not present at this version" | |
| 49 | + assert html =~ "version 1 of 2" | |
| 50 | + # The pager is the point: you can still step back to when it existed. | |
| 51 | + assert html =~ "/blob/#{added_at}/a.txt?from=#{repo.default_branch}" | |
| 52 | + end | |
| 53 | + | |
| 35 | 54 | defp write_blob(path, body) do |
| 36 | 55 | tmp = Path.join(System.tmp_dir!(), "blob-#{System.unique_integer([:positive])}") |
| 37 | 56 | File.write!(tmp, body) |
@@ -83,6 +102,53 @@ defmodule GitGudWeb.RepoLive.LogPathTest do
| 83 | 102 | assert all =~ "touch b.txt" |
| 84 | 103 | end |
| 85 | 104 | |
| 105 | + defp versions_of(repo, path) do | |
| 106 | + {:ok, sha} = GitGud.Repositories.resolve(repo, repo.default_branch) | |
| 107 | + {:ok, commits} = GitGud.Repositories.list_commits(repo, sha, limit: 100, paths: [path]) | |
| 108 | + Enum.map(commits, &Git.to_hex(&1.sha)) | |
| 109 | + end | |
| 110 | + | |
| 111 | + test "the commit hash opens the file at that version", %{conn: conn, repo: repo, base: base} do | |
| 112 | + {:ok, _lv, html} = live(conn, "#{base}/commits/#{repo.default_branch}/a.txt") | |
| 113 | + | |
| 114 | + [newest, oldest] = versions_of(repo, "a.txt") | |
| 115 | + assert html =~ "/blob/#{newest}/a.txt?from=#{repo.default_branch}" | |
| 116 | + assert html =~ "/blob/#{oldest}/a.txt?from=#{repo.default_branch}" | |
| 117 | + end | |
| 118 | + | |
| 119 | + test "the blob pager walks back and forward through versions", %{ | |
| 120 | + conn: conn, | |
| 121 | + repo: repo, | |
| 122 | + base: base | |
| 123 | + } do | |
| 124 | + [newest, oldest] = versions_of(repo, "a.txt") | |
| 125 | + | |
| 126 | + # Branch view sits at the newest version; there is nothing newer. | |
| 127 | + {:ok, _lv, html} = live(conn, "#{base}/blob/#{repo.default_branch}/a.txt") | |
| 128 | + assert html =~ "version 1 of 2" | |
| 129 | + assert html =~ "/blob/#{oldest}/a.txt?from=#{repo.default_branch}" | |
| 130 | + | |
| 131 | + # Step back. | |
| 132 | + {:ok, _lv, html} = live(conn, "#{base}/blob/#{oldest}/a.txt?from=#{repo.default_branch}") | |
| 133 | + assert html =~ "version 2 of 2" | |
| 134 | + refute html =~ "/blob/#{oldest}/a.txt?from" | |
| 135 | + assert html =~ "/blob/#{newest}/a.txt?from=#{repo.default_branch}" | |
| 136 | + end | |
| 137 | + | |
| 138 | + test "a commit outside the file's history gets no pager", %{ | |
| 139 | + conn: conn, | |
| 140 | + repo: repo, | |
| 141 | + base: base | |
| 142 | + } do | |
| 143 | + # The commit that touched only b.txt never appears in a.txt's log, | |
| 144 | + # so we can't place it — but a.txt still renders at that tree. | |
| 145 | + [b_commit] = versions_of(repo, "b.txt") | |
| 146 | + | |
| 147 | + {:ok, _lv, html} = live(conn, "#{base}/blob/#{b_commit}/a.txt?from=#{repo.default_branch}") | |
| 148 | + refute html =~ "version 1 of" | |
| 149 | + refute html =~ "← Older" | |
| 150 | + end | |
| 151 | + | |
| 86 | 152 | test "blob view links to the file's history", %{conn: conn, repo: repo, base: base} do |
| 87 | 153 | {:ok, _lv, html} = live(conn, "#{base}/blob/#{repo.default_branch}/a.txt") |
| 88 | 154 |
Parents: a986805