fabrica

hanna/fabrica

feat(web): pagination for commits and issue/PR lists

c79323e · hanna committed on 2026-07-25

Add a reusable Pagination (from ?page=&per_page=, default 10, clamped) and a
pagination_nav that renders prev/next plus a page-size selector, preserving
other query params (e.g. the issues state filter). Wire it into the commits
view and the issue/PR list (list_issues gains LIMIT/OFFSET), fetching one extra
row to detect a next page. A data-autosubmit select changes page size without a
button; a noscript Apply keeps it working without JS.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed by hannaSSH key fingerprint: SHA256:4g9cWhkAAw8gwqhJUcVbSnGEvdGwklW+1Aa/fMUu59k
6 files changed · +234 −15UnifiedSplit
assets/base.css +32 −0
@@ -595,6 +595,38 @@ body.drawer-open .drawer-backdrop {
595 flex: none;595 flex: none;
596}596}
597597
598/* Pagination controls for long lists. */
599.pagination {
600 display: flex;
601 align-items: center;
602 gap: 0.75rem;
603 margin-top: 1rem;
604 flex-wrap: wrap;
605}
606.pagination .btn.disabled {
607 opacity: 0.5;
608 pointer-events: none;
609}
610.pagination .per-page {
611 display: flex;
612 align-items: center;
613 gap: 0.4rem;
614}
615.pagination .per-page label {
616 display: flex;
617 align-items: center;
618 gap: 0.4rem;
619 margin: 0;
620 font-weight: 400;
621 color: var(--fb-fg-muted);
622}
623.pagination .per-page select {
624 width: auto;
625}
626.pagination-page {
627 margin-left: auto;
628}
629
598/* Settings: a left tab rail and the active tab's content. */630/* Settings: a left tab rail and the active tab's content. */
599.settings-layout {631.settings-layout {
600 display: flex;632 display: flex;
assets/fabrica.js +9 −0
@@ -56,6 +56,15 @@
56 });56 });
57 });57 });
5858
59 // ---- Auto-submitting selects ----
60 // A <select data-autosubmit> submits its form on change; without JS a noscript
61 // Apply button does the same.
62 document.querySelectorAll("select[data-autosubmit]").forEach(function (sel) {
63 sel.addEventListener("change", function () {
64 if (sel.form) sel.form.submit();
65 });
66 });
67
59 // ---- Progressive link fields ----68 // ---- Progressive link fields ----
60 // Show the filled links plus one empty slot; reveal more (up to the max) via69 // Show the filled links plus one empty slot; reveal more (up to the max) via
61 // the Add link button. Without JS every slot is visible.70 // the Add link button. Without JS every slot is visible.
crates/store/src/issues.rs +15 −11
@@ -125,7 +125,8 @@ impl Store {
125 Ok(row.as_ref().map(|r| self.map_issue(r)).transpose()?)125 Ok(row.as_ref().map(|r| self.map_issue(r)).transpose()?)
126 }126 }
127127
128 /// List a repo's issues or PRs, optionally filtered by state, newest first.128 /// List a repo's issues or PRs, optionally filtered by state, newest first,
129 /// bounded to `limit` rows starting at `offset`.
129 ///130 ///
130 /// # Errors131 /// # Errors
131 ///132 ///
@@ -135,21 +136,24 @@ impl Store {
135 repo_id: &str,136 repo_id: &str,
136 is_pull: bool,137 is_pull: bool,
137 state: Option<IssueState>,138 state: Option<IssueState>,
139 limit: i64,
140 offset: i64,
138 ) -> Result<Vec<Issue>, StoreError> {141 ) -> Result<Vec<Issue>, StoreError> {
139 let base = format!(142 // Placeholders after the fixed ones depend on whether a state filter is set.
140 "SELECT {ISSUE_COLUMNS} FROM issues WHERE repo_id = $1 AND is_pull = $2{} \143 let (state_clause, limit_ph, offset_ph) = if state.is_some() {
141 ORDER BY number DESC",144 (" AND state = $3", "$4", "$5")
142 if state.is_some() {145 } else {
143 " AND state = $3"146 ("", "$3", "$4")
144 } else {147 };
145 ""148 let sql = format!(
146 }149 "SELECT {ISSUE_COLUMNS} FROM issues WHERE repo_id = $1 AND is_pull = $2{state_clause} \
150 ORDER BY number DESC LIMIT {limit_ph} OFFSET {offset_ph}"
147 );151 );
148 let mut query = sqlx::query(AssertSqlSafe(base)).bind(repo_id).bind(is_pull);152 let mut query = sqlx::query(AssertSqlSafe(sql)).bind(repo_id).bind(is_pull);
149 if let Some(s) = state {153 if let Some(s) = state {
150 query = query.bind(s.as_str());154 query = query.bind(s.as_str());
151 }155 }
152 let rows = query.fetch_all(&self.pool).await?;156 let rows = query.bind(limit).bind(offset).fetch_all(&self.pool).await?;
153 rows.iter()157 rows.iter()
154 .map(|r| self.map_issue(r))158 .map(|r| self.map_issue(r))
155 .collect::<Result<_, _>>()159 .collect::<Result<_, _>>()
crates/web/src/issues.rs +11 −2
@@ -79,10 +79,16 @@ pub(crate) async fn list(
79 } else {79 } else {
80 IssueState::Open80 IssueState::Open
81 };81 };
82 let items = state82 // Paginate: fetch one extra row to detect a next page.
83 let paging = crate::repo::Pagination::from_query(uri.query());
84 let limit = i64::try_from(paging.per_page + 1).unwrap_or(i64::MAX);
85 let offset = i64::try_from(paging.offset()).unwrap_or(0);
86 let mut items = state
83 .store87 .store
84 .list_issues(&ctx.repo.id, kind.is_pull, Some(filter))88 .list_issues(&ctx.repo.id, kind.is_pull, Some(filter), limit, offset)
85 .await?;89 .await?;
90 let has_next = items.len() > paging.per_page;
91 items.truncate(paging.per_page);
86 let counts = state.store.issue_counts(&ctx.repo.id, kind.is_pull).await?;92 let counts = state.store.issue_counts(&ctx.repo.id, kind.is_pull).await?;
8793
88 // Resolve author usernames and each item's labels.94 // Resolve author usernames and each item's labels.
@@ -138,6 +144,9 @@ pub(crate) async fn list(
138 }144 }
139 }145 }
140 }146 }
147 @if has_next || paging.page > 1 {
148 (crate::repo::pagination_nav(uri.path(), uri.query(), paging, has_next))
149 }
141 }150 }
142 };151 };
143 let (jar, chrome) = build_chrome(state, jar, viewer, uri.path().to_string());152 let (jar, chrome) = build_chrome(state, jar, viewer, uri.path().to_string());
crates/web/src/repo.rs +121 −2
@@ -159,6 +159,114 @@ pub struct ListQuery {
159 pub q: Option<String>,159 pub q: Option<String>,
160}160}
161161
162/// Paging state parsed from `?page=&per_page=`, for long lists (commits, …).
163#[derive(Debug, Clone, Copy)]
164pub(crate) struct Pagination {
165 /// 1-based page number.
166 pub page: usize,
167 /// Items per page.
168 pub per_page: usize,
169}
170
171impl Pagination {
172 /// The default page size.
173 pub(crate) const DEFAULT_PER_PAGE: usize = 10;
174 /// The offered page sizes.
175 const CHOICES: [usize; 4] = [10, 25, 50, 100];
176
177 /// Parse from a raw query string, clamping to sane bounds.
178 pub(crate) fn from_query(query: Option<&str>) -> Self {
179 let (mut page, mut per_page) = (1, Self::DEFAULT_PER_PAGE);
180 if let Some(q) = query {
181 for (k, v) in url::form_urlencoded::parse(q.as_bytes()) {
182 match k.as_ref() {
183 "page" => page = v.parse::<usize>().unwrap_or(1).max(1),
184 "per_page" => {
185 per_page = v
186 .parse::<usize>()
187 .unwrap_or(Self::DEFAULT_PER_PAGE)
188 .clamp(1, 100);
189 }
190 _ => {}
191 }
192 }
193 }
194 Self { page, per_page }
195 }
196
197 /// The zero-based offset of the current page.
198 pub(crate) fn offset(self) -> usize {
199 (self.page - 1) * self.per_page
200 }
201}
202
203/// Render prev/next controls and a page-size selector for a paginated list.
204///
205/// `path` is the list URL and `query` its current query string; every query
206/// parameter other than `page`/`per_page` is preserved (e.g. the issues `state`),
207/// so the filter survives paging. `has_next` is computed by fetching one extra row.
208pub(crate) fn pagination_nav(
209 path: &str,
210 query: Option<&str>,
211 p: Pagination,
212 has_next: bool,
213) -> Markup {
214 // The non-paging query parameters, carried through on every link.
215 let kept: Vec<(String, String)> = query
216 .map(|q| {
217 url::form_urlencoded::parse(q.as_bytes())
218 .filter(|(k, _)| k != "page" && k != "per_page")
219 .map(|(k, v)| (k.into_owned(), v.into_owned()))
220 .collect()
221 })
222 .unwrap_or_default();
223 let with = |extra: &[(&str, String)]| -> String {
224 let mut s = url::form_urlencoded::Serializer::new(String::new());
225 for (k, v) in &kept {
226 s.append_pair(k, v);
227 }
228 for (k, v) in extra {
229 s.append_pair(k, v);
230 }
231 format!("{path}?{}", s.finish())
232 };
233 let link = |page: usize| {
234 with(&[
235 ("page", page.to_string()),
236 ("per_page", p.per_page.to_string()),
237 ])
238 };
239 html! {
240 nav class="pagination" aria-label="Pagination" {
241 @if p.page > 1 {
242 a class="btn" href=(link(p.page - 1)) { "← Previous" }
243 } @else {
244 span class="btn disabled" aria-disabled="true" { "← Previous" }
245 }
246 // Changing the page size returns to page 1 (the form omits `page`).
247 form class="per-page" method="get" action=(path) {
248 @for (k, v) in &kept {
249 input type="hidden" name=(k) value=(v);
250 }
251 label { "Per page "
252 select name="per_page" data-autosubmit {
253 @for n in Pagination::CHOICES {
254 option value=(n) selected[n == p.per_page] { (n) }
255 }
256 }
257 }
258 noscript { button class="btn" type="submit" { "Apply" } }
259 }
260 span class="muted pagination-page" { "Page " (p.page) }
261 @if has_next {
262 a class="btn" href=(link(p.page + 1)) { "Next →" }
263 } @else {
264 span class="btn disabled" aria-disabled="true" { "Next →" }
265 }
266 }
267 }
268}
269
162/// `GET /{owner}` — the user's profile: an info sidebar on the left, their groups270/// `GET /{owner}` — the user's profile: an info sidebar on the left, their groups
163/// and repositories (filterable) on the right.271/// and repositories (filterable) on the right.
164pub async fn user_page(272pub async fn user_page(
@@ -1353,11 +1461,19 @@ async fn commits_view(
1353 move |repo| repo.resolve_ref(&rev_name)1461 move |repo| repo.resolve_ref(&rev_name)
1354 })1462 })
1355 .await?;1463 .await?;
1356 let commits = git_read(state, &ctx.repo.id, {1464 // Paginate: fetch one extra row to detect whether a next page exists.
1465 let paging = Pagination::from_query(uri.query());
1466 let mut commits = git_read(state, &ctx.repo.id, {
1357 let rev = rev.clone();1467 let rev = rev.clone();
1358 move |repo| repo.commits(&rev, None, git::Page::first(50))1468 let page = git::Page {
1469 offset: paging.offset(),
1470 limit: paging.per_page + 1,
1471 };
1472 move |repo| repo.commits(&rev, None, page)
1359 })1473 })
1360 .await?;1474 .await?;
1475 let has_next = commits.len() > paging.per_page;
1476 commits.truncate(paging.per_page);
13611477
1362 // Verify signatures against all registered keys (bounded to this page's rows),1478 // Verify signatures against all registered keys (bounded to this page's rows),
1363 // reusing the TTL'd signature cache.1479 // reusing the TTL'd signature cache.
@@ -1417,6 +1533,9 @@ async fn commits_view(
1417 }1533 }
1418 }1534 }
1419 }1535 }
1536 @if has_next || paging.page > 1 {
1537 (pagination_nav(uri.path(), uri.query(), paging, has_next))
1538 }
1420 };1539 };
1421 let (jar, chrome) = build_chrome(state, jar, viewer, uri.path().to_string());1540 let (jar, chrome) = build_chrome(state, jar, viewer, uri.path().to_string());
1422 let title = format!("{}/{}: commits", ctx.owner.username, ctx.repo.path);1541 let title = format!("{}/{}: commits", ctx.owner.username, ctx.repo.path);
crates/web/src/tests.rs +46 −0
@@ -679,6 +679,52 @@ async fn issues_open_create_and_view() {
679}679}
680680
681#[tokio::test]681#[tokio::test]
682async fn issue_list_paginates() {
683 let (state, app) = app().await;
684 let ada = state.store.user_by_username("ada").await.unwrap().unwrap();
685 let repo = state
686 .store
687 .create_repo(NewRepo {
688 owner_id: ada.id.clone(),
689 group_id: None,
690 name: "proj".to_string(),
691 path: "proj".to_string(),
692 description: None,
693 visibility: model::Visibility::Public,
694 default_branch: "main".to_string(),
695 })
696 .await
697 .unwrap();
698 // Three open issues.
699 for i in 0..3 {
700 state
701 .store
702 .create_issue(&repo.id, &ada.id, &format!("bug {i}"), "", false)
703 .await
704 .unwrap();
705 }
706
707 // First page of two shows a Next control.
708 let res = app
709 .clone()
710 .oneshot(get("/ada/proj/-/issues?per_page=2"))
711 .await
712 .unwrap();
713 let html = body_string(res).await;
714 assert!(html.contains("class=\"pagination\""), "pagination shown");
715 assert!(html.contains("page=2"), "next link present");
716 assert_eq!(html.matches("entry-row-title").count(), 2, "two per page");
717
718 // Second page holds the remaining one.
719 let res = app
720 .oneshot(get("/ada/proj/-/issues?per_page=2&page=2"))
721 .await
722 .unwrap();
723 let html = body_string(res).await;
724 assert_eq!(html.matches("entry-row-title").count(), 1, "one on page 2");
725}
726
727#[tokio::test]
682async fn issues_404_when_disabled() {728async fn issues_404_when_disabled() {
683 let (state, app) = app().await;729 let (state, app) = app().await;
684 let ada = state.store.user_by_username("ada").await.unwrap().unwrap();730 let ada = state.store.user_by_username("ada").await.unwrap().unwrap();