Page through a file's versions from the blob view

01eb20a · Gabriel Morell · 2026-09-09 20:24

3 files +239 -16
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