Signed by hannaSSH key fingerprint: SHA256:4g9cWhkAAw8gwqhJUcVbSnGEvdGwklW+1Aa/fMUu59k
assets/base.css +32 −0
| @@ -595,6 +595,38 @@ body.drawer-open .drawer-backdrop { | |||
| 595 | flex: none; | 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 | /* 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 | }); |
| 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 | // ---- Progressive link fields ---- | 68 | // ---- Progressive link fields ---- |
| 60 | // Show the filled links plus one empty slot; reveal more (up to the max) via | 69 | // 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 | } |
| 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 | /// # Errors | 131 | /// # 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::Open | 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 | .store | 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 | .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?; |
| 87 | 93 | ||
| 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 | } |
| 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 | /// `GET /{owner}` — the user's profile: an info sidebar on the left, their groups | 270 | /// `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. |
| 164 | pub async fn user_page( | 272 | pub 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); | ||
| 1361 | 1477 | ||
| 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 | } |
| 680 | 680 | ||
| 681 | #[tokio::test] | 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 | async fn issues_404_when_disabled() { | 728 | async 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(); |