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 {
595595 flex: none;
596596 }
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+
598630 /* Settings: a left tab rail and the active tab's content. */
599631 .settings-layout {
600632 display: flex;
assets/fabrica.js +9 −0
@@ -56,6 +56,15 @@
5656 });
5757 });
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+
5968 // ---- Progressive link fields ----
6069 // Show the filled links plus one empty slot; reveal more (up to the max) via
6170 // the Add link button. Without JS every slot is visible.
crates/store/src/issues.rs +15 −11
@@ -125,7 +125,8 @@ impl Store {
125125 Ok(row.as_ref().map(|r| self.map_issue(r)).transpose()?)
126126 }
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`.
129130 ///
130131 /// # Errors
131132 ///
@@ -135,21 +136,24 @@ impl Store {
135136 repo_id: &str,
136137 is_pull: bool,
137138 state: Option<IssueState>,
139+ limit: i64,
140+ offset: i64,
138141 ) -> Result<Vec<Issue>, StoreError> {
139- let base = format!(
140- "SELECT {ISSUE_COLUMNS} FROM issues WHERE repo_id = $1 AND is_pull = $2{} \
141- ORDER BY number DESC",
142- if state.is_some() {
143- " AND state = $3"
144- } else {
145- ""
146- }
142+ // Placeholders after the fixed ones depend on whether a state filter is set.
143+ let (state_clause, limit_ph, offset_ph) = if state.is_some() {
144+ (" AND state = $3", "$4", "$5")
145+ } else {
146+ ("", "$3", "$4")
147+ };
148+ let sql = format!(
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}"
147151 );
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);
149153 if let Some(s) = state {
150154 query = query.bind(s.as_str());
151155 }
152- let rows = query.fetch_all(&self.pool).await?;
156+ let rows = query.bind(limit).bind(offset).fetch_all(&self.pool).await?;
153157 rows.iter()
154158 .map(|r| self.map_issue(r))
155159 .collect::<Result<_, _>>()
crates/web/src/issues.rs +11 −2
@@ -79,10 +79,16 @@ pub(crate) async fn list(
7979 } else {
8080 IssueState::Open
8181 };
82- let items = state
82+ // 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
8387 .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)
8589 .await?;
90+ let has_next = items.len() > paging.per_page;
91+ items.truncate(paging.per_page);
8692 let counts = state.store.issue_counts(&ctx.repo.id, kind.is_pull).await?;
8793
8894 // Resolve author usernames and each item's labels.
@@ -138,6 +144,9 @@ pub(crate) async fn list(
138144 }
139145 }
140146 }
147+ @if has_next || paging.page > 1 {
148+ (crate::repo::pagination_nav(uri.path(), uri.query(), paging, has_next))
149+ }
141150 }
142151 };
143152 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 {
159159 pub q: Option<String>,
160160 }
161161
162+/// Paging state parsed from `?page=&per_page=`, for long lists (commits, …).
163+#[derive(Debug, Clone, Copy)]
164+pub(crate) struct Pagination {
165+ /// 1-based page number.
166+ pub page: usize,
167+ /// Items per page.
168+ pub per_page: usize,
169+}
170+
171+impl 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.
208+pub(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+
162270 /// `GET /{owner}` — the user's profile: an info sidebar on the left, their groups
163271 /// and repositories (filterable) on the right.
164272 pub async fn user_page(
@@ -1353,11 +1461,19 @@ async fn commits_view(
13531461 move |repo| repo.resolve_ref(&rev_name)
13541462 })
13551463 .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, {
13571467 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)
13591473 })
13601474 .await?;
1475+ let has_next = commits.len() > paging.per_page;
1476+ commits.truncate(paging.per_page);
13611477
13621478 // Verify signatures against all registered keys (bounded to this page's rows),
13631479 // reusing the TTL'd signature cache.
@@ -1417,6 +1533,9 @@ async fn commits_view(
14171533 }
14181534 }
14191535 }
1536+ @if has_next || paging.page > 1 {
1537+ (pagination_nav(uri.path(), uri.query(), paging, has_next))
1538+ }
14201539 };
14211540 let (jar, chrome) = build_chrome(state, jar, viewer, uri.path().to_string());
14221541 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() {
679679 }
680680
681681 #[tokio::test]
682+async 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]
682728 async fn issues_404_when_disabled() {
683729 let (state, app) = app().await;
684730 let ada = state.store.user_by_username("ada").await.unwrap().unwrap();