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 {
2119.user-ref {2119.user-ref {
2120 font-weight: 600;2120 font-weight: 600;
2121}2121}
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}
2122/* Notifications inbox. */2127/* Notifications inbox. */
2123.notif-head {2128.notif-head {
2124 display: flex;2129 display: flex;
crates/web/src/admin.rs +10 −1
@@ -563,13 +563,21 @@ pub async fn invites(
563 RequireAdmin(user): RequireAdmin,563 RequireAdmin(user): RequireAdmin,
564 jar: CookieJar,564 jar: CookieJar,
565 uri: Uri,565 uri: Uri,
566 Query(pager): Query<Pager>,
566) -> AppResult<Response> {567) -> AppResult<Response> {
567 // Invites only matter when self-registration is closed; otherwise the page568 // Invites only matter when self-registration is closed; otherwise the page
568 // is hidden and its route redirects to the overview.569 // is hidden and its route redirects to the overview.
569 if state.allow_registration() {570 if state.allow_registration() {
570 return Ok(Redirect::to("/admin").into_response());571 return Ok(Redirect::to("/admin").into_response());
571 }572 }
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();
573 let base = state.config.instance.url.trim_end_matches('/').to_string();581 let base = state.config.instance.url.trim_end_matches('/').to_string();
574 let (_, chrome) = build_chrome(582 let (_, chrome) = build_chrome(
575 &state,583 &state,
@@ -608,6 +616,7 @@ pub async fn invites(
608 }616 }
609 }617 }
610 }618 }
619 (pager_nav("/admin/invites", page, total))
611 form method="post" action="/admin/invites" {620 form method="post" action="/admin/invites" {
612 input type="hidden" name="_csrf" value=(csrf);621 input type="hidden" name="_csrf" value=(csrf);
613 div class="admin-form-row admin-form-row-last" {622 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(
247 let viewer_id: Option<String> = viewer.as_ref().map(|u| u.id.clone());247 let viewer_id: Option<String> = viewer.as_ref().map(|u| u.id.clone());
248 let branches = crate::repo::branch_names(state, &ctx.repo.id).await;248 let branches = crate::repo::branch_names(state, &ctx.repo.id).await;
249249
250 let mut comment_rows = Vec::with_capacity(comments.len());250 // Paginate long threads (the pager only appears past one page).
251 for c in &comments {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 {
252 comment_rows.push((c, username(state, &c.author_id).await));255 comment_rows.push((c, username(state, &c.author_id).await));
253 }256 }
254257
@@ -281,6 +284,9 @@ pub(crate) async fn view(
281 (comment_card(cauthor, &base, &c.body, c.created_at,284 (comment_card(cauthor, &base, &c.body, c.created_at,
282 editable.then_some((action.as_str(), csrf.as_str()))))285 editable.then_some((action.as_str(), csrf.as_str()))))
283 }286 }
287 @if comments_next || paging.page > 1 {
288 (crate::repo::pagination_nav(uri.path(), uri.query(), paging, comments_next))
289 }
284 @if issue.locked() { (locked_note(can_write)) }290 @if issue.locked() { (locked_note(can_write)) }
285 @if can_write || (is_author && !issue.locked()) {291 @if can_write || (is_author && !issue.locked()) {
286 (comment_box(&issue, &csrf, can_write))292 (comment_box(&issue, &csrf, can_write))
crates/web/src/pages.rs +28 −6
@@ -73,7 +73,7 @@ pub async fn home(
73 let body = dashboard_body(&state, &u, &lq.q.unwrap_or_default(), &uri).await?;73 let body = dashboard_body(&state, &u, &lq.q.unwrap_or_default(), &uri).await?;
74 Ok((jar, page(&chrome, "Dashboard", body)).into_response())74 Ok((jar, page(&chrome, "Dashboard", body)).into_response())
75 } else {75 } else {
76 let body = explore_repos_body(&state, "").await?;76 let body = explore_repos_body(&state, "", &uri).await?;
77 Ok((jar, page(&chrome, "Explore", body)).into_response())77 Ok((jar, page(&chrome, "Explore", body)).into_response())
78 }78 }
79}79}
@@ -87,7 +87,7 @@ pub async fn explore(
87 axum::extract::Query(lq): axum::extract::Query<repo::ListQuery>,87 axum::extract::Query(lq): axum::extract::Query<repo::ListQuery>,
88) -> AppResult<Response> {88) -> AppResult<Response> {
89 let (jar, chrome) = build_chrome(&state, jar, user, uri.path().to_string());89 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?;
91 Ok((jar, page(&chrome, "Explore", body)).into_response())91 Ok((jar, page(&chrome, "Explore", body)).into_response())
92}92}
9393
@@ -100,7 +100,7 @@ pub async fn explore_users(
100 axum::extract::Query(lq): axum::extract::Query<repo::ListQuery>,100 axum::extract::Query(lq): axum::extract::Query<repo::ListQuery>,
101) -> AppResult<Response> {101) -> AppResult<Response> {
102 let (jar, chrome) = build_chrome(&state, jar, user, uri.path().to_string());102 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?;
104 Ok((jar, page(&chrome, "Explore", body)).into_response())104 Ok((jar, page(&chrome, "Explore", body)).into_response())
105}105}
106106
@@ -119,7 +119,7 @@ fn explore_subnav(active: &str) -> Markup {
119}119}
120120
121/// The Explore repositories tab: sub-nav, a search box, and the public repos.121/// The Explore repositories tab: sub-nav, a search box, and the public repos.
122async fn explore_repos_body(state: &AppState, q: &str) -> AppResult<Markup> {122async fn explore_repos_body(state: &AppState, q: &str, uri: &Uri) -> AppResult<Markup> {
123 let needle = q.trim().to_lowercase();123 let needle = q.trim().to_lowercase();
124 let all = state.store.list_repos().await?;124 let all = state.store.list_repos().await?;
125 let public: Vec<_> = all125 let public: Vec<_> = all
@@ -147,6 +147,8 @@ async fn explore_repos_body(state: &AppState, q: &str) -> AppResult<Markup> {
147 .then(|| RepoEntry::global(owner, r))147 .then(|| RepoEntry::global(owner, r))
148 })148 })
149 .collect();149 .collect();
150 let paging = repo::Pagination::from_query(uri.query());
151 let (entries, has_next) = repo::page_slice(&entries, paging);
150 Ok(html! {152 Ok(html! {
151 (explore_subnav("repos"))153 (explore_subnav("repos"))
152 form class="search-row" method="get" action="/explore" {154 form class="search-row" method="get" action="/explore" {
@@ -155,11 +157,14 @@ async fn explore_repos_body(state: &AppState, q: &str) -> AppResult<Markup> {
155 button class="btn btn-primary" type="submit" { "Search" }157 button class="btn btn-primary" type="submit" { "Search" }
156 }158 }
157 (repo_card("", "No public repositories.", &entries))159 (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 }
158 })163 })
159}164}
160165
161/// The Explore users tab: sub-nav, a search box, and the user directory.166/// The Explore users tab: sub-nav, a search box, and the user directory.
162async fn explore_users_body(state: &AppState, q: &str) -> AppResult<Markup> {167async fn explore_users_body(state: &AppState, q: &str, uri: &Uri) -> AppResult<Markup> {
163 let needle = q.trim().to_lowercase();168 let needle = q.trim().to_lowercase();
164 let users: Vec<User> = state169 let users: Vec<User> = state
165 .store170 .store
@@ -175,6 +180,8 @@ async fn explore_users_body(state: &AppState, q: &str) -> AppResult<Markup> {
175 .is_some_and(|d| d.to_lowercase().contains(&needle))180 .is_some_and(|d| d.to_lowercase().contains(&needle))
176 })181 })
177 .collect();182 .collect();
183 let paging = repo::Pagination::from_query(uri.query());
184 let (users, has_next) = repo::page_slice(&users, paging);
178 Ok(html! {185 Ok(html! {
179 (explore_subnav("users"))186 (explore_subnav("users"))
180 form class="search-row" method="get" action="/explore/users" {187 form class="search-row" method="get" action="/explore/users" {
@@ -183,6 +190,9 @@ async fn explore_users_body(state: &AppState, q: &str) -> AppResult<Markup> {
183 button class="btn btn-primary" type="submit" { "Search" }190 button class="btn btn-primary" type="submit" { "Search" }
184 }191 }
185 (user_card(&users))192 (user_card(&users))
193 @if has_next || paging.page > 1 {
194 (repo::pagination_nav(uri.path(), uri.query(), paging, has_next))
195 }
186 })196 })
187}197}
188198
@@ -218,6 +228,9 @@ fn user_card(users: &[User]) -> Markup {
218/// The dashboard: the viewer's contribution activity and recent commits, beside228/// The dashboard: the viewer's contribution activity and recent commits, beside
219/// a filterable list of their repositories.229/// a filterable list of their repositories.
220async fn dashboard_body(state: &AppState, user: &User, q: &str, uri: &Uri) -> AppResult<Markup> {230async 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;
221 let now = now_ms();234 let now = now_ms();
222 let activity = crate::activity::compute(state, user, now).await?;235 let activity = crate::activity::compute(state, user, now).await?;
223 let paging = repo::Pagination::from_query(uri.query());236 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
229 .filter(|r| needle.is_empty() || r.path.to_lowercase().contains(&needle))242 .filter(|r| needle.is_empty() || r.path.to_lowercase().contains(&needle))
230 .map(|r| RepoEntry::owned(&user.username, r))243 .map(|r| RepoEntry::owned(&user.username, r))
231 .collect();244 .collect();
245 let total = entries.len();
246 let shown: Vec<RepoEntry> = entries.into_iter().take(SIDEBAR_REPOS).collect();
232247
233 Ok(html! {248 Ok(html! {
234 div class="dashboard" {249 div class="dashboard" {
@@ -242,7 +257,14 @@ async fn dashboard_body(state: &AppState, user: &User, q: &str, uri: &Uri) -> Ap
242 placeholder="Search repositories…" aria-label="Search repositories";257 placeholder="Search repositories…" aria-label="Search repositories";
243 button class="btn btn-primary" type="submit" { "Search" }258 button class="btn btn-primary" type="submit" { "Search" }
244 }259 }
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 }
246 }268 }
247 }269 }
248 })270 })
crates/web/src/pulls.rs +6 −0
@@ -153,6 +153,9 @@ pub(crate) async fn view(
153 let (jar, chrome) = build_chrome(state, jar, viewer, uri.path().to_string());153 let (jar, chrome) = build_chrome(state, jar, viewer, uri.path().to_string());
154 let csrf = chrome.csrf.clone();154 let csrf = chrome.csrf.clone();
155 let repo_base = format!("/{}/{}", ctx.owner.username, ctx.repo.path);155 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);
156 let body = html! {159 let body = html! {
157 (repo_header(state, &ctx, Tab::Pulls, &ctx.repo.default_branch, &branches))160 (repo_header(state, &ctx, Tab::Pulls, &ctx.repo.default_branch, &branches))
158 div class="issue-view" {161 div class="issue-view" {
@@ -182,6 +185,9 @@ pub(crate) async fn view(
182 (comment_card(cauthor, &repo_base, &c.body, c.created_at,185 (comment_card(cauthor, &repo_base, &c.body, c.created_at,
183 editable.then_some((action.as_str(), csrf.as_str()))))186 editable.then_some((action.as_str(), csrf.as_str()))))
184 }187 }
188 @if comments_next || paging.page > 1 {
189 (crate::repo::pagination_nav(uri.path(), uri.query(), paging, comments_next))
190 }
185 (merge_panel(&issue, &pr, mergeable, blocked, &csrf))191 (merge_panel(&issue, &pr, mergeable, blocked, &csrf))
186 @if issue.locked() { (locked_note(can_write)) }192 @if issue.locked() { (locked_note(can_write)) }
187 @if can_write || (is_author && !issue.locked()) {193 @if can_write || (is_author && !issue.locked()) {
crates/web/src/repo.rs +51 −6
@@ -237,6 +237,17 @@ impl Pagination {
237 }237 }
238}238}
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.
244pub(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
240/// Render prev/next controls and a page-size selector for a paginated list.251/// Render prev/next controls and a page-size selector for a paginated list.
241///252///
242/// `path` is the list URL and `query` its current query string; every query253/// `path` is the list URL and `query` its current query string; every query
@@ -328,6 +339,7 @@ pub async fn user_page(
328 let q = lq.q.unwrap_or_default();339 let q = lq.q.unwrap_or_default();
329 let needle = q.trim().to_lowercase();340 let needle = q.trim().to_lowercase();
330 let base = format!("/{}", owner.username);341 let base = format!("/{}", owner.username);
342 let paging = Pagination::from_query(uri.query());
331343
332 let content = match tab {344 let content = match tab {
333 ProfileTab::Repos => {345 ProfileTab::Repos => {
@@ -341,6 +353,7 @@ pub async fn user_page(
341 })353 })
342 .map(|r| RepoEntry::owned(&owner.username, r))354 .map(|r| RepoEntry::owned(&owner.username, r))
343 .collect();355 .collect();
356 let (entries, has_next) = page_slice(&entries, paging);
344 html! {357 html! {
345 form class="search-row" method="get" {358 form class="search-row" method="get" {
346 input type="search" name="q" value=(q)359 input type="search" name="q" value=(q)
@@ -348,6 +361,9 @@ pub async fn user_page(
348 button class="btn btn-primary" type="submit" { "Search" }361 button class="btn btn-primary" type="submit" { "Search" }
349 }362 }
350 (repo_card("", "No repositories.", &entries))363 (repo_card("", "No repositories.", &entries))
364 @if has_next || paging.page > 1 {
365 (pagination_nav(uri.path(), uri.query(), paging, has_next))
366 }
351 }367 }
352 }368 }
353 ProfileTab::Groups => {369 ProfileTab::Groups => {
@@ -385,15 +401,33 @@ pub async fn user_page(
385 .collect();401 .collect();
386 // Replace the owner-id label with the username where we can.402 // Replace the owner-id label with the username where we can.
387 let entries = resolve_bookmark_labels(&state, &visible, entries).await;403 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 }
389 }411 }
390 ProfileTab::Followers => {412 ProfileTab::Followers => {
391 let users = state.store.followers(&owner.id).await?;413 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 }
393 }421 }
394 ProfileTab::Following => {422 ProfileTab::Following => {
395 let users = state.store.following(&owner.id).await?;423 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 }
397 }431 }
398 };432 };
399433
@@ -1147,10 +1181,12 @@ async fn branches_view(
1147 uri: &Uri,1181 uri: &Uri,
1148 ctx: RepoCtx,1182 ctx: RepoCtx,
1149) -> AppResult<Response> {1183) -> AppResult<Response> {
1150 let branches = git_read(state, &ctx.repo.id, git::Repo::branches).await?;1184 let all = git_read(state, &ctx.repo.id, git::Repo::branches).await?;
1151 let branch_list: Vec<String> = branches.iter().map(|b| b.name.clone()).collect();1185 let branch_list: Vec<String> = all.iter().map(|b| b.name.clone()).collect();
1152 let base = format!("/{}/{}", ctx.owner.username, ctx.repo.path);1186 let base = format!("/{}/{}", ctx.owner.username, ctx.repo.path);
1153 let now = crate::now_ms();1187 let now = crate::now_ms();
1188 let paging = Pagination::from_query(uri.query());
1189 let (branches, has_next) = page_slice(&all, paging);
1154 let body = html! {1190 let body = html! {
1155 (repo_header(state, &ctx, Tab::Branches, &ctx.repo.default_branch, &branch_list))1191 (repo_header(state, &ctx, Tab::Branches, &ctx.repo.default_branch, &branch_list))
1156 ul class="entry-list card" {1192 ul class="entry-list card" {
@@ -1178,6 +1214,9 @@ async fn branches_view(
1178 }1214 }
1179 }1215 }
1180 }1216 }
1217 @if has_next || paging.page > 1 {
1218 (pagination_nav(uri.path(), uri.query(), paging, has_next))
1219 }
1181 };1220 };
1182 let (jar, chrome) = build_chrome(state, jar, viewer, uri.path().to_string());1221 let (jar, chrome) = build_chrome(state, jar, viewer, uri.path().to_string());
1183 let title = format!("{}/{}: branches", ctx.owner.username, ctx.repo.path);1222 let title = format!("{}/{}: branches", ctx.owner.username, ctx.repo.path);
@@ -1193,9 +1232,11 @@ async fn tags_view(
1193 uri: &Uri,1232 uri: &Uri,
1194 ctx: RepoCtx,1233 ctx: RepoCtx,
1195) -> AppResult<Response> {1234) -> 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?;
1197 let branches = branch_names(state, &ctx.repo.id).await;1236 let branches = branch_names(state, &ctx.repo.id).await;
1198 let base = format!("/{}/{}", ctx.owner.username, ctx.repo.path);1237 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);
1199 let body = html! {1240 let body = html! {
1200 (repo_header(state, &ctx, Tab::Tags, &ctx.repo.default_branch, &branches))1241 (repo_header(state, &ctx, Tab::Tags, &ctx.repo.default_branch, &branches))
1201 @if tags.is_empty() {1242 @if tags.is_empty() {
@@ -1218,6 +1259,9 @@ async fn tags_view(
1218 }1259 }
1219 }1260 }
1220 }1261 }
1262 @if has_next || paging.page > 1 {
1263 (pagination_nav(uri.path(), uri.query(), paging, has_next))
1264 }
1221 }1265 }
1222 };1266 };
1223 let (jar, chrome) = build_chrome(state, jar, viewer, uri.path().to_string());1267 let (jar, chrome) = build_chrome(state, jar, viewer, uri.path().to_string());
@@ -2812,6 +2856,7 @@ async fn visible_repos(
2812}2856}
28132857
2814/// One row in a repository listing card.2858/// One row in a repository listing card.
2859#[derive(Clone)]
2815pub(crate) struct RepoEntry {2860pub(crate) struct RepoEntry {
2816 /// Link target.2861 /// Link target.
2817 pub(crate) href: String,2862 pub(crate) href: String,