fabrica

hanna/fabrica

feat(web): paginate the remaining unbounded list views

d578a14 · hanna committed on 2026-07-26

Add a page_slice helper and wire pagination (reusing pagination_nav)
through the lists that fetched everything:

- Explore repos and Explore users
- Profile tabs: Repositories, Bookmarks, Followers, Following
- Branches and Tags
- Long issue/PR comment threads (the pager only appears past one page)
- Admin sign-up invites (admin pager)

The dashboard sidebar is capped with a "View all N repositories" link to
the paginated profile tab, since its `?page` already drives the activity
feed. Issue/PR lists and commits were already paginated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed by hannaSSH key fingerprint: SHA256:4g9cWhkAAw8gwqhJUcVbSnGEvdGwklW+1Aa/fMUu59k
6 files changed · +108 −15UnifiedSplit
assets/base.css +5 −0
@@ -2119,6 +2119,11 @@ pre.code {
21192119 .user-ref {
21202120 font-weight: 600;
21212121 }
2122+/* "View all N repositories" link under the dashboard sidebar list. */
2123+.dashboard-viewall {
2124+ margin: 0.6rem 0 0;
2125+ font-size: 0.9em;
2126+}
21222127 /* Notifications inbox. */
21232128 .notif-head {
21242129 display: flex;
crates/web/src/admin.rs +10 −1
@@ -563,13 +563,21 @@ pub async fn invites(
563563 RequireAdmin(user): RequireAdmin,
564564 jar: CookieJar,
565565 uri: Uri,
566+ Query(pager): Query<Pager>,
566567 ) -> AppResult<Response> {
567568 // Invites only matter when self-registration is closed; otherwise the page
568569 // is hidden and its route redirects to the overview.
569570 if state.allow_registration() {
570571 return Ok(Redirect::to("/admin").into_response());
571572 }
572- let invites = state.store.list_signup_invites().await?;
573+ let (page, offset) = pager.resolve();
574+ let all = state.store.list_signup_invites().await?;
575+ let total = i64::try_from(all.len()).unwrap_or(i64::MAX);
576+ let invites: Vec<_> = all
577+ .into_iter()
578+ .skip(usize::try_from(offset).unwrap_or(0))
579+ .take(usize::try_from(PAGE_SIZE).unwrap_or(20))
580+ .collect();
573581 let base = state.config.instance.url.trim_end_matches('/').to_string();
574582 let (_, chrome) = build_chrome(
575583 &state,
@@ -608,6 +616,7 @@ pub async fn invites(
608616 }
609617 }
610618 }
619+ (pager_nav("/admin/invites", page, total))
611620 form method="post" action="/admin/invites" {
612621 input type="hidden" name="_csrf" value=(csrf);
613622 div class="admin-form-row admin-form-row-last" {
crates/web/src/issues.rs +8 −2
@@ -247,8 +247,11 @@ pub(crate) async fn view(
247247 let viewer_id: Option<String> = viewer.as_ref().map(|u| u.id.clone());
248248 let branches = crate::repo::branch_names(state, &ctx.repo.id).await;
249249
250- let mut comment_rows = Vec::with_capacity(comments.len());
251- for c in &comments {
250+ // Paginate long threads (the pager only appears past one page).
251+ let paging = crate::repo::Pagination::from_query(uri.query());
252+ let (page_comments, comments_next) = crate::repo::page_slice(&comments, paging);
253+ let mut comment_rows = Vec::with_capacity(page_comments.len());
254+ for c in &page_comments {
252255 comment_rows.push((c, username(state, &c.author_id).await));
253256 }
254257
@@ -281,6 +284,9 @@ pub(crate) async fn view(
281284 (comment_card(cauthor, &base, &c.body, c.created_at,
282285 editable.then_some((action.as_str(), csrf.as_str()))))
283286 }
287+ @if comments_next || paging.page > 1 {
288+ (crate::repo::pagination_nav(uri.path(), uri.query(), paging, comments_next))
289+ }
284290 @if issue.locked() { (locked_note(can_write)) }
285291 @if can_write || (is_author && !issue.locked()) {
286292 (comment_box(&issue, &csrf, can_write))
crates/web/src/pages.rs +28 −6
@@ -73,7 +73,7 @@ pub async fn home(
7373 let body = dashboard_body(&state, &u, &lq.q.unwrap_or_default(), &uri).await?;
7474 Ok((jar, page(&chrome, "Dashboard", body)).into_response())
7575 } else {
76- let body = explore_repos_body(&state, "").await?;
76+ let body = explore_repos_body(&state, "", &uri).await?;
7777 Ok((jar, page(&chrome, "Explore", body)).into_response())
7878 }
7979 }
@@ -87,7 +87,7 @@ pub async fn explore(
8787 axum::extract::Query(lq): axum::extract::Query<repo::ListQuery>,
8888 ) -> AppResult<Response> {
8989 let (jar, chrome) = build_chrome(&state, jar, user, uri.path().to_string());
90- let body = explore_repos_body(&state, &lq.q.unwrap_or_default()).await?;
90+ let body = explore_repos_body(&state, &lq.q.unwrap_or_default(), &uri).await?;
9191 Ok((jar, page(&chrome, "Explore", body)).into_response())
9292 }
9393
@@ -100,7 +100,7 @@ pub async fn explore_users(
100100 axum::extract::Query(lq): axum::extract::Query<repo::ListQuery>,
101101 ) -> AppResult<Response> {
102102 let (jar, chrome) = build_chrome(&state, jar, user, uri.path().to_string());
103- let body = explore_users_body(&state, &lq.q.unwrap_or_default()).await?;
103+ let body = explore_users_body(&state, &lq.q.unwrap_or_default(), &uri).await?;
104104 Ok((jar, page(&chrome, "Explore", body)).into_response())
105105 }
106106
@@ -119,7 +119,7 @@ fn explore_subnav(active: &str) -> Markup {
119119 }
120120
121121 /// The Explore repositories tab: sub-nav, a search box, and the public repos.
122-async fn explore_repos_body(state: &AppState, q: &str) -> AppResult<Markup> {
122+async fn explore_repos_body(state: &AppState, q: &str, uri: &Uri) -> AppResult<Markup> {
123123 let needle = q.trim().to_lowercase();
124124 let all = state.store.list_repos().await?;
125125 let public: Vec<_> = all
@@ -147,6 +147,8 @@ async fn explore_repos_body(state: &AppState, q: &str) -> AppResult<Markup> {
147147 .then(|| RepoEntry::global(owner, r))
148148 })
149149 .collect();
150+ let paging = repo::Pagination::from_query(uri.query());
151+ let (entries, has_next) = repo::page_slice(&entries, paging);
150152 Ok(html! {
151153 (explore_subnav("repos"))
152154 form class="search-row" method="get" action="/explore" {
@@ -155,11 +157,14 @@ async fn explore_repos_body(state: &AppState, q: &str) -> AppResult<Markup> {
155157 button class="btn btn-primary" type="submit" { "Search" }
156158 }
157159 (repo_card("", "No public repositories.", &entries))
160+ @if has_next || paging.page > 1 {
161+ (repo::pagination_nav(uri.path(), uri.query(), paging, has_next))
162+ }
158163 })
159164 }
160165
161166 /// The Explore users tab: sub-nav, a search box, and the user directory.
162-async fn explore_users_body(state: &AppState, q: &str) -> AppResult<Markup> {
167+async fn explore_users_body(state: &AppState, q: &str, uri: &Uri) -> AppResult<Markup> {
163168 let needle = q.trim().to_lowercase();
164169 let users: Vec<User> = state
165170 .store
@@ -175,6 +180,8 @@ async fn explore_users_body(state: &AppState, q: &str) -> AppResult<Markup> {
175180 .is_some_and(|d| d.to_lowercase().contains(&needle))
176181 })
177182 .collect();
183+ let paging = repo::Pagination::from_query(uri.query());
184+ let (users, has_next) = repo::page_slice(&users, paging);
178185 Ok(html! {
179186 (explore_subnav("users"))
180187 form class="search-row" method="get" action="/explore/users" {
@@ -183,6 +190,9 @@ async fn explore_users_body(state: &AppState, q: &str) -> AppResult<Markup> {
183190 button class="btn btn-primary" type="submit" { "Search" }
184191 }
185192 (user_card(&users))
193+ @if has_next || paging.page > 1 {
194+ (repo::pagination_nav(uri.path(), uri.query(), paging, has_next))
195+ }
186196 })
187197 }
188198
@@ -218,6 +228,9 @@ fn user_card(users: &[User]) -> Markup {
218228 /// The dashboard: the viewer's contribution activity and recent commits, beside
219229 /// a filterable list of their repositories.
220230 async fn dashboard_body(state: &AppState, user: &User, q: &str, uri: &Uri) -> AppResult<Markup> {
231+ // The sidebar shows a capped slice (the dashboard's `?page` drives the
232+ // activity feed); "View all" leads to the paginated profile repos tab.
233+ const SIDEBAR_REPOS: usize = 12;
221234 let now = now_ms();
222235 let activity = crate::activity::compute(state, user, now).await?;
223236 let paging = repo::Pagination::from_query(uri.query());
@@ -229,6 +242,8 @@ async fn dashboard_body(state: &AppState, user: &User, q: &str, uri: &Uri) -> Ap
229242 .filter(|r| needle.is_empty() || r.path.to_lowercase().contains(&needle))
230243 .map(|r| RepoEntry::owned(&user.username, r))
231244 .collect();
245+ let total = entries.len();
246+ let shown: Vec<RepoEntry> = entries.into_iter().take(SIDEBAR_REPOS).collect();
232247
233248 Ok(html! {
234249 div class="dashboard" {
@@ -242,7 +257,14 @@ async fn dashboard_body(state: &AppState, user: &User, q: &str, uri: &Uri) -> Ap
242257 placeholder="Search repositories…" aria-label="Search repositories";
243258 button class="btn btn-primary" type="submit" { "Search" }
244259 }
245- (repo_card("Repositories", "You have no repositories yet.", &entries))
260+ (repo_card("Repositories", "You have no repositories yet.", &shown))
261+ @if total > SIDEBAR_REPOS {
262+ p class="dashboard-viewall" {
263+ a href={ "/" (user.username) "?tab=repos" } {
264+ "View all " (total) " repositories →"
265+ }
266+ }
267+ }
246268 }
247269 }
248270 })
crates/web/src/pulls.rs +6 −0
@@ -153,6 +153,9 @@ pub(crate) async fn view(
153153 let (jar, chrome) = build_chrome(state, jar, viewer, uri.path().to_string());
154154 let csrf = chrome.csrf.clone();
155155 let repo_base = format!("/{}/{}", ctx.owner.username, ctx.repo.path);
156+ // Paginate long comment threads (the pager only shows past one page).
157+ let paging = crate::repo::Pagination::from_query(uri.query());
158+ let (comment_rows, comments_next) = crate::repo::page_slice(&comment_rows, paging);
156159 let body = html! {
157160 (repo_header(state, &ctx, Tab::Pulls, &ctx.repo.default_branch, &branches))
158161 div class="issue-view" {
@@ -182,6 +185,9 @@ pub(crate) async fn view(
182185 (comment_card(cauthor, &repo_base, &c.body, c.created_at,
183186 editable.then_some((action.as_str(), csrf.as_str()))))
184187 }
188+ @if comments_next || paging.page > 1 {
189+ (crate::repo::pagination_nav(uri.path(), uri.query(), paging, comments_next))
190+ }
185191 (merge_panel(&issue, &pr, mergeable, blocked, &csrf))
186192 @if issue.locked() { (locked_note(can_write)) }
187193 @if can_write || (is_author && !issue.locked()) {
crates/web/src/repo.rs +51 −6
@@ -237,6 +237,17 @@ impl Pagination {
237237 }
238238 }
239239
240+/// Slice an already-fetched list to the current page, returning the page's items
241+/// and whether a next page exists. For lists that are filtered/searched in Rust
242+/// (so SQL `LIMIT`/`OFFSET` cannot bound them), this keeps the rendered output
243+/// bounded even when the full set is large.
244+pub(crate) fn page_slice<T: Clone>(items: &[T], p: Pagination) -> (Vec<T>, bool) {
245+ let start = p.offset().min(items.len());
246+ let end = start.saturating_add(p.per_page).min(items.len());
247+ let has_next = end < items.len();
248+ (items[start..end].to_vec(), has_next)
249+}
250+
240251 /// Render prev/next controls and a page-size selector for a paginated list.
241252 ///
242253 /// `path` is the list URL and `query` its current query string; every query
@@ -328,6 +339,7 @@ pub async fn user_page(
328339 let q = lq.q.unwrap_or_default();
329340 let needle = q.trim().to_lowercase();
330341 let base = format!("/{}", owner.username);
342+ let paging = Pagination::from_query(uri.query());
331343
332344 let content = match tab {
333345 ProfileTab::Repos => {
@@ -341,6 +353,7 @@ pub async fn user_page(
341353 })
342354 .map(|r| RepoEntry::owned(&owner.username, r))
343355 .collect();
356+ let (entries, has_next) = page_slice(&entries, paging);
344357 html! {
345358 form class="search-row" method="get" {
346359 input type="search" name="q" value=(q)
@@ -348,6 +361,9 @@ pub async fn user_page(
348361 button class="btn btn-primary" type="submit" { "Search" }
349362 }
350363 (repo_card("", "No repositories.", &entries))
364+ @if has_next || paging.page > 1 {
365+ (pagination_nav(uri.path(), uri.query(), paging, has_next))
366+ }
351367 }
352368 }
353369 ProfileTab::Groups => {
@@ -385,15 +401,33 @@ pub async fn user_page(
385401 .collect();
386402 // Replace the owner-id label with the username where we can.
387403 let entries = resolve_bookmark_labels(&state, &visible, entries).await;
388- html! { (repo_card("", "No bookmarks yet.", &entries)) }
404+ let (entries, has_next) = page_slice(&entries, paging);
405+ html! {
406+ (repo_card("", "No bookmarks yet.", &entries))
407+ @if has_next || paging.page > 1 {
408+ (pagination_nav(uri.path(), uri.query(), paging, has_next))
409+ }
410+ }
389411 }
390412 ProfileTab::Followers => {
391413 let users = state.store.followers(&owner.id).await?;
392- user_list(&state, &users, viewer.as_ref(), "No followers yet.").await?
414+ let (page, has_next) = page_slice(&users, paging);
415+ html! {
416+ (user_list(&state, &page, viewer.as_ref(), "No followers yet.").await?)
417+ @if has_next || paging.page > 1 {
418+ (pagination_nav(uri.path(), uri.query(), paging, has_next))
419+ }
420+ }
393421 }
394422 ProfileTab::Following => {
395423 let users = state.store.following(&owner.id).await?;
396- user_list(&state, &users, viewer.as_ref(), "Not following anyone yet.").await?
424+ let (page, has_next) = page_slice(&users, paging);
425+ html! {
426+ (user_list(&state, &page, viewer.as_ref(), "Not following anyone yet.").await?)
427+ @if has_next || paging.page > 1 {
428+ (pagination_nav(uri.path(), uri.query(), paging, has_next))
429+ }
430+ }
397431 }
398432 };
399433
@@ -1147,10 +1181,12 @@ async fn branches_view(
11471181 uri: &Uri,
11481182 ctx: RepoCtx,
11491183 ) -> AppResult<Response> {
1150- let branches = git_read(state, &ctx.repo.id, git::Repo::branches).await?;
1151- let branch_list: Vec<String> = branches.iter().map(|b| b.name.clone()).collect();
1184+ let all = git_read(state, &ctx.repo.id, git::Repo::branches).await?;
1185+ let branch_list: Vec<String> = all.iter().map(|b| b.name.clone()).collect();
11521186 let base = format!("/{}/{}", ctx.owner.username, ctx.repo.path);
11531187 let now = crate::now_ms();
1188+ let paging = Pagination::from_query(uri.query());
1189+ let (branches, has_next) = page_slice(&all, paging);
11541190 let body = html! {
11551191 (repo_header(state, &ctx, Tab::Branches, &ctx.repo.default_branch, &branch_list))
11561192 ul class="entry-list card" {
@@ -1178,6 +1214,9 @@ async fn branches_view(
11781214 }
11791215 }
11801216 }
1217+ @if has_next || paging.page > 1 {
1218+ (pagination_nav(uri.path(), uri.query(), paging, has_next))
1219+ }
11811220 };
11821221 let (jar, chrome) = build_chrome(state, jar, viewer, uri.path().to_string());
11831222 let title = format!("{}/{}: branches", ctx.owner.username, ctx.repo.path);
@@ -1193,9 +1232,11 @@ async fn tags_view(
11931232 uri: &Uri,
11941233 ctx: RepoCtx,
11951234 ) -> AppResult<Response> {
1196- let tags = git_read(state, &ctx.repo.id, git::Repo::tags).await?;
1235+ let all = git_read(state, &ctx.repo.id, git::Repo::tags).await?;
11971236 let branches = branch_names(state, &ctx.repo.id).await;
11981237 let base = format!("/{}/{}", ctx.owner.username, ctx.repo.path);
1238+ let paging = Pagination::from_query(uri.query());
1239+ let (tags, has_next) = page_slice(&all, paging);
11991240 let body = html! {
12001241 (repo_header(state, &ctx, Tab::Tags, &ctx.repo.default_branch, &branches))
12011242 @if tags.is_empty() {
@@ -1218,6 +1259,9 @@ async fn tags_view(
12181259 }
12191260 }
12201261 }
1262+ @if has_next || paging.page > 1 {
1263+ (pagination_nav(uri.path(), uri.query(), paging, has_next))
1264+ }
12211265 }
12221266 };
12231267 let (jar, chrome) = build_chrome(state, jar, viewer, uri.path().to_string());
@@ -2812,6 +2856,7 @@ async fn visible_repos(
28122856 }
28132857
28142858 /// One row in a repository listing card.
2859+#[derive(Clone)]
28152860 pub(crate) struct RepoEntry {
28162861 /// Link target.
28172862 pub(crate) href: String,