Signed by hannaSSH key fingerprint: SHA256:4g9cWhkAAw8gwqhJUcVbSnGEvdGwklW+1Aa/fMUu59k
assets/base.css +32 −0
| @@ -595,6 +595,38 @@ body.drawer-open .drawer-backdrop { | |||
| 595 | 595 | flex: none; | |
| 596 | 596 | } | |
| 597 | 597 | ||
| 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 | 630 | /* Settings: a left tab rail and the active tab's content. */ | |
| 599 | 631 | .settings-layout { | |
| 600 | 632 | display: flex; | |
assets/fabrica.js +9 −0
| @@ -56,6 +56,15 @@ | |||
| 56 | 56 | }); | |
| 57 | 57 | }); | |
| 58 | 58 | ||
| 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 | 68 | // ---- Progressive link fields ---- | |
| 60 | 69 | // Show the filled links plus one empty slot; reveal more (up to the max) via | |
| 61 | 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 | 125 | Ok(row.as_ref().map(|r| self.map_issue(r)).transpose()?) | |
| 126 | 126 | } | |
| 127 | 127 | ||
| 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 | 131 | /// # Errors | |
| 131 | 132 | /// | |
| @@ -135,21 +136,24 @@ impl Store { | |||
| 135 | 136 | repo_id: &str, | |
| 136 | 137 | is_pull: bool, | |
| 137 | 138 | state: Option<IssueState>, | |
| 139 | + | limit: i64, | |
| 140 | + | offset: i64, | |
| 138 | 141 | ) -> 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}" | |
| 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 | 153 | if let Some(s) = state { | |
| 150 | 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 | 157 | rows.iter() | |
| 154 | 158 | .map(|r| self.map_issue(r)) | |
| 155 | 159 | .collect::<Result<_, _>>() | |
crates/web/src/issues.rs +11 −2
| @@ -79,10 +79,16 @@ pub(crate) async fn list( | |||
| 79 | 79 | } else { | |
| 80 | 80 | IssueState::Open | |
| 81 | 81 | }; | |
| 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 | |
| 83 | 87 | .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 | 89 | .await?; | |
| 90 | + | let has_next = items.len() > paging.per_page; | |
| 91 | + | items.truncate(paging.per_page); | |
| 86 | 92 | let counts = state.store.issue_counts(&ctx.repo.id, kind.is_pull).await?; | |
| 87 | 93 | ||
| 88 | 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 | 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 | 159 | pub q: Option<String>, | |
| 160 | 160 | } | |
| 161 | 161 | ||
| 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 | + | ||
| 162 | 270 | /// `GET /{owner}` — the user's profile: an info sidebar on the left, their groups | |
| 163 | 271 | /// and repositories (filterable) on the right. | |
| 164 | 272 | pub async fn user_page( | |
| @@ -1353,11 +1461,19 @@ async fn commits_view( | |||
| 1353 | 1461 | move |repo| repo.resolve_ref(&rev_name) | |
| 1354 | 1462 | }) | |
| 1355 | 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 | 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 | 1474 | .await?; | |
| 1475 | + | let has_next = commits.len() > paging.per_page; | |
| 1476 | + | commits.truncate(paging.per_page); | |
| 1361 | 1477 | ||
| 1362 | 1478 | // Verify signatures against all registered keys (bounded to this page's rows), | |
| 1363 | 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 | 1540 | let (jar, chrome) = build_chrome(state, jar, viewer, uri.path().to_string()); | |
| 1422 | 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 | } | |
| 680 | 680 | ||
| 681 | 681 | #[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] | |
| 682 | 728 | async fn issues_404_when_disabled() { | |
| 683 | 729 | let (state, app) = app().await; | |
| 684 | 730 | let ada = state.store.user_by_username("ada").await.unwrap().unwrap(); | |