Rebuild /repositories as cards with filter, scope and sort

9ac35f7 · Gabriel Morell · 2026-09-09 21:17

3 files +392 -42
Message
{commit_body(@commit)}

Files changed

modified lib/git_gud/repositories.ex
+25 −0
@@ -1593,6 +1593,31 @@ defmodule GitGud.Repositories do
1593 1593 end
1594 1594 end
1595 1595
1596 + @doc """
1597 + Top language per repository, keyed by repo id, for listings.
1598 +
1599 + Takes the most recently computed stats row for each repo rather than
1600 + resolving each default branch to a shaone query for the whole
1601 + page, against N ref reads. The tradeoff is that a repo pushed since
1602 + its last stats run shows the previous commit's top language, which is
1603 + fine for a listing badge.
1604 + """
1605 + def top_languages_for(repo_ids) when is_list(repo_ids) do
1606 + from(l in GitCommitLanguageStats,
1607 + where: l.repository_id in ^repo_ids,
1608 + order_by: [asc: l.repository_id, desc: l.computed_at],
1609 + distinct: l.repository_id,
1610 + select: {l.repository_id, l.languages}
1611 + )
1612 + |> Repo.all()
1613 + |> Enum.reduce(%{}, fn {rid, languages}, acc ->
1614 + case languages do
1615 + %{"items" => [%{"language" => lang} | _]} when is_binary(lang) -> Map.put(acc, rid, lang)
1616 + _ -> acc
1617 + end
1618 + end)
1619 + end
1620 +
1596 1621 @doc """
1597 1622 Walk the tree at `sha` and bucket blob bytes by `detected_language`.
1598 1623 Writes the result into `git_commit_language_stats`. Synchronous
modified lib/git_gud_web/live/repo_live/index.ex
+236 −42
@@ -1,77 +1,271 @@
1 1 defmodule GitGudWeb.RepoLive.Index do
2 2 use GitGudWeb, :live_view
3 3
4 + alias GitGud.LanguageColors
4 5 alias GitGud.Repositories
5
6 + alias GitGud.Repositories.Repository
6 7 alias GitGud.Repositories.Storage
8 + alias GitGud.Workflows
9 +
10 + @sorts ~w(pushed name size)
7 11
8 12 @impl true
9 13 def mount(_params, _session, socket) do
10 14 repos =
11 15 socket.assigns.current_scope
12 16 |> Repositories.list_repositories()
13 |> GitGud.Repo.preload([:owner, :organization])
17 + |> GitGud.Repo.preload([:owner, :organization, parent_repository: [:owner, :organization]])
14 18 |> Enum.map(fn r -> Map.put(r, :handle, namespace_handle(r)) end)
15 19
20 + ids = Enum.map(repos, & &1.id)
21 +
22 + # Live-update the CI badges as runs change anywhere.
23 + if connected?(socket) do
24 + Phoenix.PubSub.subscribe(GitGud.PubSub, Workflows.ci_topic())
25 + end
26 +
16 27 {:ok,
17 28 socket
18 29 |> assign(:page_title, "Repositories")
19 |> stream(:repos, repos)}
30 + |> assign(:repos, repos)
31 + |> assign(:languages, Repositories.top_languages_for(ids))
32 + |> assign(:ci_status, Workflows.latest_run_status_by_repo(ids))
33 + |> assign(:query, "")
34 + |> assign(:scope_filter, "all")
35 + |> assign(:sort, "pushed")}
20 36 end
21 37
22 38 defp namespace_handle(%{organization: %{handle: h}}) when is_binary(h), do: h
23 39 defp namespace_handle(%{owner: user}), do: Storage.owner_handle(user)
24 40
41 + @impl true
42 + def handle_info({:ci_run_changed, _run_id}, socket) do
43 + ids = Enum.map(socket.assigns.repos, & &1.id)
44 + {:noreply, assign(socket, :ci_status, Workflows.latest_run_status_by_repo(ids))}
45 + end
46 +
47 + def handle_info(_msg, socket), do: {:noreply, socket}
48 +
49 + @impl true
50 + def handle_event("filter", params, socket) do
51 + {:noreply,
52 + socket
53 + |> assign(:query, params["query"] || socket.assigns.query)
54 + |> assign(:sort, sort_param(params["sort"], socket.assigns.sort))}
55 + end
56 +
57 + def handle_event("scope", %{"scope" => scope}, socket)
58 + when scope in ~w(all mine) do
59 + {:noreply, assign(socket, :scope_filter, scope)}
60 + end
61 +
62 + defp sort_param(sort, _fallback) when sort in @sorts, do: sort
63 + defp sort_param(_sort, fallback), do: fallback
64 +
65 + # Filtering and sorting run over the already-loaded list — the query
66 + # is unbounded either way, and re-querying per keystroke would only
67 + # add round-trips.
68 + defp visible(assigns) do
69 + assigns.repos
70 + |> Enum.filter(&matches?(&1, assigns.query))
71 + |> Enum.filter(&in_scope?(&1, assigns.scope_filter, assigns.current_scope))
72 + |> sort_by(assigns.sort)
73 + end
74 +
75 + defp matches?(_repo, ""), do: true
76 +
77 + defp matches?(repo, query) do
78 + needle = String.downcase(query)
79 +
80 + String.contains?(String.downcase("#{repo.handle}/#{repo.name}"), needle) or
81 + String.contains?(String.downcase(repo.description || ""), needle)
82 + end
83 +
84 + defp in_scope?(_repo, "all", _scope), do: true
85 + defp in_scope?(repo, "mine", %{user: %{id: uid}}), do: repo.owner_id == uid
86 + defp in_scope?(_repo, "mine", _scope), do: false
87 +
88 + defp sort_by(repos, "name"), do: Enum.sort_by(repos, &"#{&1.handle}/#{&1.name}")
89 + defp sort_by(repos, "size"), do: Enum.sort_by(repos, &(&1.size_kb || 0), :desc)
90 +
91 + defp sort_by(repos, _pushed) do
92 + # `nil` pushed_at (never pushed) sorts last rather than first.
93 + Enum.sort_by(repos, &{&1.pushed_at != nil, &1.pushed_at}, :desc)
94 + end
95 +
96 + defp signed_in?(%{user: %{}}), do: true
97 + defp signed_in?(_), do: false
98 +
99 + # daisyUI badge class for a repo's latest CI status.
100 + defp ci_badge("success"), do: "badge-success"
101 + defp ci_badge("failure"), do: "badge-error"
102 + defp ci_badge(s) when s in ~w(running queued blocked), do: "badge-warning"
103 + defp ci_badge(_), do: "badge-ghost"
104 +
105 + defp format_size(kb) when is_integer(kb) and kb >= 1_048_576,
106 + do: "#{Float.round(kb / 1_048_576, 1)} GB"
107 +
108 + defp format_size(kb) when is_integer(kb) and kb >= 1024,
109 + do: "#{Float.round(kb / 1024, 1)} MB"
110 +
111 + defp format_size(kb) when is_integer(kb) and kb > 0, do: "#{kb} KB"
112 + defp format_size(_), do: nil
113 +
114 + defp pushed_ago(nil), do: "never pushed"
115 +
116 + defp pushed_ago(%DateTime{} = dt) do
117 + case DateTime.diff(DateTime.utc_now(), dt) do
118 + s when s < 60 -> "pushed just now"
119 + s when s < 3600 -> "pushed #{div(s, 60)}m ago"
120 + s when s < 86_400 -> "pushed #{div(s, 3600)}h ago"
121 + s when s < 2_592_000 -> "pushed #{div(s, 86_400)}d ago"
122 + s when s < 31_536_000 -> "pushed #{div(s, 2_592_000)}mo ago"
123 + s -> "pushed #{div(s, 31_536_000)}y ago"
124 + end
125 + end
126 +
127 + defp exact_time(nil), do: "no pushes yet"
128 + defp exact_time(dt), do: Calendar.strftime(dt, "%Y-%m-%d %H:%M UTC")
129 +
25 130 @impl true
26 131 def render(assigns) do
132 + assigns = assign(assigns, :visible, visible(assigns))
133 +
27 134 ~H"""
28 135 <Layouts.app flash={@flash} current_scope={@current_scope}>
29 136 <div class="space-y-4">
30 <div class="flex items-center justify-between">
31 <h1 class="text-2xl font-semibold">Repositories</h1>
32 <%= if @current_scope && @current_scope.user do %>
33 <div class="flex items-center gap-2">
34 <.link
35 navigate={~p"/repositories/import"}
36 class="btn btn-outline"
37 id="import-repo-link"
38 >
39 <.icon name="hero-arrow-down-tray" class="size-4" /> Import
40 </.link>
41 <.link
42 navigate={~p"/repositories/new"}
43 class="btn btn-primary"
44 id="new-repo-link"
45 >
46 <.icon name="hero-plus" class="size-4" /> New
47 </.link>
48 </div>
49 <% end %>
50 </div>
137 + <header class="flex items-center justify-between gap-4">
138 + <div>
139 + <h1 class="text-2xl font-semibold">Repositories</h1>
140 + <p class="text-sm opacity-70 mt-1">
141 + {length(@visible)} of {length(@repos)} shown
142 + </p>
143 + </div>
144 + <div :if={signed_in?(@current_scope)} class="flex items-center gap-2 shrink-0">
145 + <.link navigate={~p"/repositories/import"} class="btn btn-outline" id="import-repo-link">
146 + <.icon name="hero-arrow-down-tray" class="size-4" /> Import
147 + </.link>
148 + <.link navigate={~p"/repositories/new"} class="btn btn-primary" id="new-repo-link">
149 + <.icon name="hero-plus" class="size-4" /> New
150 + </.link>
151 + </div>
152 + </header>
51 153
52 <ul id="repos" phx-update="stream" class="divide-y divide-base-300">
53 <li id="repos-empty" class="py-6 hidden only:block text-sm opacity-60">
54 No repositories yet.
55 </li>
56 <li :for={{id, repo} <- @streams.repos} id={id} class="py-4">
57 <.link
58 navigate={~p"/r/#{repo.handle}/#{repo.name}"}
59 class="font-mono text-primary hover:underline"
154 + <div class="flex flex-wrap items-center gap-2">
155 + <form phx-change="filter" class="contents">
156 + <input
157 + type="search"
158 + name="query"
159 + value={@query}
160 + placeholder="Filter by name or description…"
161 + class="input input-bordered input-sm flex-1 min-w-48"
162 + />
163 + <select name="sort" class="select select-bordered select-sm">
164 + <option value="pushed" selected={@sort == "pushed"}>Recently pushed</option>
165 + <option value="name" selected={@sort == "name"}>Name</option>
166 + <option value="size" selected={@sort == "size"}>Size</option>
167 + </select>
168 + </form>
169 +
170 + <div :if={signed_in?(@current_scope)} class="join">
171 + <button
172 + type="button"
173 + phx-click="scope"
174 + phx-value-scope="all"
175 + class={["btn btn-sm join-item", @scope_filter == "all" && "btn-active"]}
60 176 >
61 {repo.handle}/{repo.name}
62 </.link>
63 <p :if={repo.description} class="text-sm opacity-70">{repo.description}</p>
64 <p class="text-xs opacity-50 mt-1">
65 {repo.visibility} ·
66 <span :if={repo.pushed_at}>pushed {format_time(repo.pushed_at)}</span>
67 <span :if={!repo.pushed_at}>no pushes yet</span>
68 </p>
177 + All
178 + </button>
179 + <button
180 + type="button"
181 + phx-click="scope"
182 + phx-value-scope="mine"
183 + class={["btn btn-sm join-item", @scope_filter == "mine" && "btn-active"]}
184 + >
185 + Yours
186 + </button>
187 + </div>
188 + </div>
189 +
190 + <p :if={@repos == []} class="py-12 text-center text-sm opacity-60">
191 + No repositories yet.
192 + </p>
193 +
194 + <p :if={@repos != [] and @visible == []} class="py-12 text-center text-sm opacity-60">
195 + Nothing matches that filter.
196 + </p>
197 +
198 + <ul class="space-y-3">
199 + <li
200 + :for={repo <- @visible}
201 + id={"repo-#{repo.id}"}
202 + class={[
203 + "rounded-lg border border-base-300 p-4 transition-colors hover:border-primary/50",
204 + repo.archived && "opacity-60"
205 + ]}
206 + >
207 + <div class="flex items-start gap-3 min-w-0">
208 + <.avatar name={repo.handle} size="md" alt={repo.handle} />
209 +
210 + <div class="min-w-0 flex-1">
211 + <div class="flex items-center gap-2 flex-wrap">
212 + <.link
213 + navigate={~p"/r/#{repo.handle}/#{repo.name}"}
214 + class="font-mono text-primary hover:underline truncate"
215 + >
216 + {repo.handle}/{repo.name}
217 + </.link>
218 + <span :if={repo.visibility != "public"} class="badge badge-xs badge-ghost">
219 + {repo.visibility}
220 + </span>
221 + <span :if={repo.archived} class="badge badge-xs badge-warning">archived</span>
222 + <span :if={repo.upstream_url} class="badge badge-xs badge-ghost">imported</span>
223 + <.link
224 + :if={@ci_status[repo.id]}
225 + navigate={~p"/r/#{repo.handle}/#{repo.name}/actions"}
226 + class={["badge badge-xs", ci_badge(@ci_status[repo.id])]}
227 + title={"latest CI run: #{@ci_status[repo.id]}"}
228 + >
229 + {@ci_status[repo.id]}
230 + </.link>
231 + </div>
232 +
233 + <p
234 + :if={Repository.fork?(repo) and repo.parent_repository}
235 + class="text-xs opacity-60 mt-0.5"
236 + >
237 + forked from
238 + <.link
239 + navigate={
240 + ~p"/r/#{namespace_handle(repo.parent_repository)}/#{repo.parent_repository.name}"
241 + }
242 + class="link link-hover font-mono"
243 + >
244 + {namespace_handle(repo.parent_repository)}/{repo.parent_repository.name}
245 + </.link>
246 + </p>
247 +
248 + <p :if={repo.description} class="text-sm opacity-70 mt-1 line-clamp-2">
249 + {repo.description}
250 + </p>
251 +
252 + <div class="text-xs opacity-60 mt-2 flex items-center gap-3 flex-wrap">
253 + <span :if={@languages[repo.id]} class="flex items-center gap-1.5">
254 + <span
255 + class="inline-block size-2.5 rounded-full"
256 + style={"background-color: " <> LanguageColors.for(@languages[repo.id]) <> ";"}
257 + />
258 + <span class="font-mono">{@languages[repo.id]}</span>
259 + </span>
260 + <span :if={format_size(repo.size_kb)}>{format_size(repo.size_kb)}</span>
261 + <span title={exact_time(repo.pushed_at)}>{pushed_ago(repo.pushed_at)}</span>
262 + </div>
263 + </div>
264 + </div>
69 265 </li>
70 266 </ul>
71 267 </div>
72 268 </Layouts.app>
73 269 """
74 270 end
75
76 defp format_time(dt), do: Calendar.strftime(dt, "%Y-%m-%d %H:%M")
77 271 end
added test/git_gud_web/live/repo_live/index_test.exs
+131 −0
@@ -0,0 +1,131 @@
1 +defmodule GitGudWeb.RepoLive.IndexTest do
2 + use GitGudWeb.ConnCase, async: false
3 +
4 + import Phoenix.LiveViewTest
5 + import GitGud.AccountsFixtures
6 + import GitGud.ForgeFixtures
7 +
8 + alias GitGud.Repositories
9 +
10 + setup :register_and_log_in_user
11 +
12 + test "cards carry description, visibility and push recency", %{conn: conn, user: user} do
13 + {_user, repo} =
14 + repository_fixture(%{owner: user, name: "index-card", visibility: "private"})
15 +
16 + {:ok, repo} = Repositories.update_repository(repo, %{"description" => "A described repo"})
17 +
18 + {:ok, _lv, html} = live(conn, ~p"/repositories")
19 +
20 + assert html =~ "#{user.handle}/index-card"
21 + assert html =~ "A described repo"
22 + assert html =~ "private"
23 + # Never pushed, so the footer says so rather than showing a date.
24 + assert html =~ "never pushed"
25 + refute repo.pushed_at
26 + end
27 +
28 + test "public repos are visible without an account", %{user: user} do
29 + {_user, _repo} =
30 + repository_fixture(%{owner: user, name: "index-public", visibility: "public"})
31 +
32 + {:ok, _lv, html} = live(build_conn(), ~p"/repositories")
33 +
34 + assert html =~ "#{user.handle}/index-public"
35 + end
36 +
37 + test "the filter box narrows the list", %{conn: conn, user: user} do
38 + {_user, _} = repository_fixture(%{owner: user, name: "alpha-widget"})
39 + {_user, _} = repository_fixture(%{owner: user, name: "beta-gadget"})
40 +
41 + {:ok, lv, html} = live(conn, ~p"/repositories")
42 + assert html =~ "alpha-widget"
43 + assert html =~ "beta-gadget"
44 +
45 + html = lv |> form("form[phx-change=filter]", %{query: "alpha"}) |> render_change()
46 +
47 + assert html =~ "alpha-widget"
48 + refute html =~ "beta-gadget"
49 + end
50 +
51 + test "a filter matching nothing says so", %{conn: conn, user: user} do
52 + {_user, _} = repository_fixture(%{owner: user, name: "only-one"})
53 +
54 + {:ok, lv, _html} = live(conn, ~p"/repositories")
55 +
56 + html = lv |> form("form[phx-change=filter]", %{query: "zzz-no-match"}) |> render_change()
57 +
58 + assert html =~ "Nothing matches that filter."
59 + end
60 +
61 + test "the Yours scope hides other people's repos", %{conn: conn, user: user} do
62 + {_user, _} = repository_fixture(%{owner: user, name: "mine-repo", visibility: "public"})
63 +
64 + other = user_fixture()
65 + {_other, _} = repository_fixture(%{owner: other, name: "theirs-repo", visibility: "public"})
66 +
67 + {:ok, lv, html} = live(conn, ~p"/repositories")
68 + assert html =~ "theirs-repo"
69 +
70 + html = lv |> element("button[phx-value-scope=mine]") |> render_click()
71 +
72 + assert html =~ "mine-repo"
73 + refute html =~ "theirs-repo"
74 + end
75 +
76 + test "sorting by name reorders the list", %{conn: conn, user: user} do
77 + {_user, _} = repository_fixture(%{owner: user, name: "zzz-last"})
78 + {_user, _} = repository_fixture(%{owner: user, name: "aaa-first"})
79 +
80 + {:ok, lv, _html} = live(conn, ~p"/repositories")
81 +
82 + html = lv |> form("form[phx-change=filter]", %{sort: "name"}) |> render_change()
83 +
84 + assert :binary.match(html, "aaa-first") < :binary.match(html, "zzz-last")
85 + end
86 +
87 + test "the anonymous view offers no scope toggle", %{user: user} do
88 + {_user, _} = repository_fixture(%{owner: user, name: "anon-visible", visibility: "public"})
89 +
90 + {:ok, lv, _html} = live(build_conn(), ~p"/repositories")
91 +
92 + refute has_element?(lv, "button[phx-value-scope=mine]")
93 + end
94 +
95 + test "a fork names its parent", %{conn: conn, user: user} do
96 + {_user, parent} =
97 + repository_fixture(%{owner: user, name: "upstream-repo", visibility: "public"})
98 +
99 + {_user, fork} = repository_fixture(%{owner: user, name: "downstream-repo"})
100 +
101 + {:ok, _fork} =
102 + fork
103 + |> Ecto.Changeset.change(parent_repository_id: parent.id)
104 + |> GitGud.Repo.update()
105 +
106 + {:ok, _lv, html} = live(conn, ~p"/repositories")
107 +
108 + assert html =~ "forked from"
109 + assert html =~ "#{user.handle}/upstream-repo"
110 + end
111 +
112 + test "archived repos are badged", %{conn: conn, user: user} do
113 + {_user, repo} = repository_fixture(%{owner: user, name: "old-repo"})
114 + {:ok, _repo} = Repositories.update_repository(repo, %{"archived" => true})
115 +
116 + {:ok, _lv, html} = live(conn, ~p"/repositories")
117 +
118 + assert html =~ "archived"
119 + end
120 +
121 + test "the header counts shown against total", %{conn: conn, user: user} do
122 + {_user, _} = repository_fixture(%{owner: user, name: "counted-one"})
123 + {_user, _} = repository_fixture(%{owner: user, name: "counted-two"})
124 +
125 + {:ok, lv, _html} = live(conn, ~p"/repositories")
126 +
127 + html = lv |> form("form[phx-change=filter]", %{query: "counted-one"}) |> render_change()
128 +
129 + assert html =~ "1 of 2 shown"
130 + end
131 +end

Parents: 7eeae4a