Signed by hannaSSH key fingerprint: SHA256:4g9cWhkAAw8gwqhJUcVbSnGEvdGwklW+1Aa/fMUu59k
assets/base.css +5 −0
| @@ -2119,6 +2119,11 @@ pre.code { | |||
| 2119 | 2119 | .user-ref { | |
| 2120 | 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 | 2127 | /* Notifications inbox. */ | |
| 2123 | 2128 | .notif-head { | |
| 2124 | 2129 | display: flex; | |
crates/web/src/admin.rs +10 −1
| @@ -563,13 +563,21 @@ pub async fn invites( | |||
| 563 | 563 | RequireAdmin(user): RequireAdmin, | |
| 564 | 564 | jar: CookieJar, | |
| 565 | 565 | uri: Uri, | |
| 566 | + | Query(pager): Query<Pager>, | |
| 566 | 567 | ) -> AppResult<Response> { | |
| 567 | 568 | // Invites only matter when self-registration is closed; otherwise the page | |
| 568 | 569 | // is hidden and its route redirects to the overview. | |
| 569 | 570 | if state.allow_registration() { | |
| 570 | 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 | 581 | let base = state.config.instance.url.trim_end_matches('/').to_string(); | |
| 574 | 582 | let (_, chrome) = build_chrome( | |
| 575 | 583 | &state, | |
| @@ -608,6 +616,7 @@ pub async fn invites( | |||
| 608 | 616 | } | |
| 609 | 617 | } | |
| 610 | 618 | } | |
| 619 | + | (pager_nav("/admin/invites", page, total)) | |
| 611 | 620 | form method="post" action="/admin/invites" { | |
| 612 | 621 | input type="hidden" name="_csrf" value=(csrf); | |
| 613 | 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 | 247 | let viewer_id: Option<String> = viewer.as_ref().map(|u| u.id.clone()); | |
| 248 | 248 | let branches = crate::repo::branch_names(state, &ctx.repo.id).await; | |
| 249 | 249 | ||
| 250 | - | let mut comment_rows = Vec::with_capacity(comments.len()); | |
| 251 | - | for c in &comments { | |
| 250 | + | // Paginate long threads (the pager only appears past one page). | |
| 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 | 255 | comment_rows.push((c, username(state, &c.author_id).await)); | |
| 253 | 256 | } | |
| 254 | 257 | ||
| @@ -281,6 +284,9 @@ pub(crate) async fn view( | |||
| 281 | 284 | (comment_card(cauthor, &base, &c.body, c.created_at, | |
| 282 | 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 | 290 | @if issue.locked() { (locked_note(can_write)) } | |
| 285 | 291 | @if can_write || (is_author && !issue.locked()) { | |
| 286 | 292 | (comment_box(&issue, &csrf, can_write)) | |
crates/web/src/pages.rs +28 −6
| @@ -73,7 +73,7 @@ pub async fn home( | |||
| 73 | 73 | let body = dashboard_body(&state, &u, &lq.q.unwrap_or_default(), &uri).await?; | |
| 74 | 74 | Ok((jar, page(&chrome, "Dashboard", body)).into_response()) | |
| 75 | 75 | } else { | |
| 76 | - | let body = explore_repos_body(&state, "").await?; | |
| 76 | + | let body = explore_repos_body(&state, "", &uri).await?; | |
| 77 | 77 | Ok((jar, page(&chrome, "Explore", body)).into_response()) | |
| 78 | 78 | } | |
| 79 | 79 | } | |
| @@ -87,7 +87,7 @@ pub async fn explore( | |||
| 87 | 87 | axum::extract::Query(lq): axum::extract::Query<repo::ListQuery>, | |
| 88 | 88 | ) -> AppResult<Response> { | |
| 89 | 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 | 91 | Ok((jar, page(&chrome, "Explore", body)).into_response()) | |
| 92 | 92 | } | |
| 93 | 93 | ||
| @@ -100,7 +100,7 @@ pub async fn explore_users( | |||
| 100 | 100 | axum::extract::Query(lq): axum::extract::Query<repo::ListQuery>, | |
| 101 | 101 | ) -> AppResult<Response> { | |
| 102 | 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 | 104 | Ok((jar, page(&chrome, "Explore", body)).into_response()) | |
| 105 | 105 | } | |
| 106 | 106 | ||
| @@ -119,7 +119,7 @@ fn explore_subnav(active: &str) -> Markup { | |||
| 119 | 119 | } | |
| 120 | 120 | ||
| 121 | 121 | /// The Explore repositories tab: sub-nav, a search box, and the public repos. | |
| 122 | - | async fn explore_repos_body(state: &AppState, q: &str) -> AppResult<Markup> { | |
| 122 | + | async fn explore_repos_body(state: &AppState, q: &str, uri: &Uri) -> AppResult<Markup> { | |
| 123 | 123 | let needle = q.trim().to_lowercase(); | |
| 124 | 124 | let all = state.store.list_repos().await?; | |
| 125 | 125 | let public: Vec<_> = all | |
| @@ -147,6 +147,8 @@ async fn explore_repos_body(state: &AppState, q: &str) -> AppResult<Markup> { | |||
| 147 | 147 | .then(|| RepoEntry::global(owner, r)) | |
| 148 | 148 | }) | |
| 149 | 149 | .collect(); | |
| 150 | + | let paging = repo::Pagination::from_query(uri.query()); | |
| 151 | + | let (entries, has_next) = repo::page_slice(&entries, paging); | |
| 150 | 152 | Ok(html! { | |
| 151 | 153 | (explore_subnav("repos")) | |
| 152 | 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 | 157 | button class="btn btn-primary" type="submit" { "Search" } | |
| 156 | 158 | } | |
| 157 | 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 | } | |
| 160 | 165 | ||
| 161 | 166 | /// The Explore users tab: sub-nav, a search box, and the user directory. | |
| 162 | - | async fn explore_users_body(state: &AppState, q: &str) -> AppResult<Markup> { | |
| 167 | + | async fn explore_users_body(state: &AppState, q: &str, uri: &Uri) -> AppResult<Markup> { | |
| 163 | 168 | let needle = q.trim().to_lowercase(); | |
| 164 | 169 | let users: Vec<User> = state | |
| 165 | 170 | .store | |
| @@ -175,6 +180,8 @@ async fn explore_users_body(state: &AppState, q: &str) -> AppResult<Markup> { | |||
| 175 | 180 | .is_some_and(|d| d.to_lowercase().contains(&needle)) | |
| 176 | 181 | }) | |
| 177 | 182 | .collect(); | |
| 183 | + | let paging = repo::Pagination::from_query(uri.query()); | |
| 184 | + | let (users, has_next) = repo::page_slice(&users, paging); | |
| 178 | 185 | Ok(html! { | |
| 179 | 186 | (explore_subnav("users")) | |
| 180 | 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 | 190 | button class="btn btn-primary" type="submit" { "Search" } | |
| 184 | 191 | } | |
| 185 | 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 | } | |
| 188 | 198 | ||
| @@ -218,6 +228,9 @@ fn user_card(users: &[User]) -> Markup { | |||
| 218 | 228 | /// The dashboard: the viewer's contribution activity and recent commits, beside | |
| 219 | 229 | /// a filterable list of their repositories. | |
| 220 | 230 | async 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 | 234 | let now = now_ms(); | |
| 222 | 235 | let activity = crate::activity::compute(state, user, now).await?; | |
| 223 | 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 | 242 | .filter(|r| needle.is_empty() || r.path.to_lowercase().contains(&needle)) | |
| 230 | 243 | .map(|r| RepoEntry::owned(&user.username, r)) | |
| 231 | 244 | .collect(); | |
| 245 | + | let total = entries.len(); | |
| 246 | + | let shown: Vec<RepoEntry> = entries.into_iter().take(SIDEBAR_REPOS).collect(); | |
| 232 | 247 | ||
| 233 | 248 | Ok(html! { | |
| 234 | 249 | div class="dashboard" { | |
| @@ -242,7 +257,14 @@ async fn dashboard_body(state: &AppState, user: &User, q: &str, uri: &Uri) -> Ap | |||
| 242 | 257 | placeholder="Search repositories…" aria-label="Search repositories"; | |
| 243 | 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 | 153 | let (jar, chrome) = build_chrome(state, jar, viewer, uri.path().to_string()); | |
| 154 | 154 | let csrf = chrome.csrf.clone(); | |
| 155 | 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 | 159 | let body = html! { | |
| 157 | 160 | (repo_header(state, &ctx, Tab::Pulls, &ctx.repo.default_branch, &branches)) | |
| 158 | 161 | div class="issue-view" { | |
| @@ -182,6 +185,9 @@ pub(crate) async fn view( | |||
| 182 | 185 | (comment_card(cauthor, &repo_base, &c.body, c.created_at, | |
| 183 | 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 | 191 | (merge_panel(&issue, &pr, mergeable, blocked, &csrf)) | |
| 186 | 192 | @if issue.locked() { (locked_note(can_write)) } | |
| 187 | 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 | } | |
| 239 | 239 | ||
| 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. | |
| 244 | + | pub(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 | 251 | /// Render prev/next controls and a page-size selector for a paginated list. | |
| 241 | 252 | /// | |
| 242 | 253 | /// `path` is the list URL and `query` its current query string; every query | |
| @@ -328,6 +339,7 @@ pub async fn user_page( | |||
| 328 | 339 | let q = lq.q.unwrap_or_default(); | |
| 329 | 340 | let needle = q.trim().to_lowercase(); | |
| 330 | 341 | let base = format!("/{}", owner.username); | |
| 342 | + | let paging = Pagination::from_query(uri.query()); | |
| 331 | 343 | ||
| 332 | 344 | let content = match tab { | |
| 333 | 345 | ProfileTab::Repos => { | |
| @@ -341,6 +353,7 @@ pub async fn user_page( | |||
| 341 | 353 | }) | |
| 342 | 354 | .map(|r| RepoEntry::owned(&owner.username, r)) | |
| 343 | 355 | .collect(); | |
| 356 | + | let (entries, has_next) = page_slice(&entries, paging); | |
| 344 | 357 | html! { | |
| 345 | 358 | form class="search-row" method="get" { | |
| 346 | 359 | input type="search" name="q" value=(q) | |
| @@ -348,6 +361,9 @@ pub async fn user_page( | |||
| 348 | 361 | button class="btn btn-primary" type="submit" { "Search" } | |
| 349 | 362 | } | |
| 350 | 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 | 369 | ProfileTab::Groups => { | |
| @@ -385,15 +401,33 @@ pub async fn user_page( | |||
| 385 | 401 | .collect(); | |
| 386 | 402 | // Replace the owner-id label with the username where we can. | |
| 387 | 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 | 412 | ProfileTab::Followers => { | |
| 391 | 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 | 422 | ProfileTab::Following => { | |
| 395 | 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 | }; | |
| 399 | 433 | ||
| @@ -1147,10 +1181,12 @@ async fn branches_view( | |||
| 1147 | 1181 | uri: &Uri, | |
| 1148 | 1182 | ctx: RepoCtx, | |
| 1149 | 1183 | ) -> AppResult<Response> { | |
| 1150 | - | let branches = git_read(state, &ctx.repo.id, git::Repo::branches).await?; | |
| 1151 | - | let branch_list: Vec<String> = branches.iter().map(|b| b.name.clone()).collect(); | |
| 1184 | + | let all = git_read(state, &ctx.repo.id, git::Repo::branches).await?; | |
| 1185 | + | let branch_list: Vec<String> = all.iter().map(|b| b.name.clone()).collect(); | |
| 1152 | 1186 | let base = format!("/{}/{}", ctx.owner.username, ctx.repo.path); | |
| 1153 | 1187 | let now = crate::now_ms(); | |
| 1188 | + | let paging = Pagination::from_query(uri.query()); | |
| 1189 | + | let (branches, has_next) = page_slice(&all, paging); | |
| 1154 | 1190 | let body = html! { | |
| 1155 | 1191 | (repo_header(state, &ctx, Tab::Branches, &ctx.repo.default_branch, &branch_list)) | |
| 1156 | 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 | 1221 | let (jar, chrome) = build_chrome(state, jar, viewer, uri.path().to_string()); | |
| 1183 | 1222 | let title = format!("{}/{}: branches", ctx.owner.username, ctx.repo.path); | |
| @@ -1193,9 +1232,11 @@ async fn tags_view( | |||
| 1193 | 1232 | uri: &Uri, | |
| 1194 | 1233 | ctx: RepoCtx, | |
| 1195 | 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 | 1236 | let branches = branch_names(state, &ctx.repo.id).await; | |
| 1198 | 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 | 1240 | let body = html! { | |
| 1200 | 1241 | (repo_header(state, &ctx, Tab::Tags, &ctx.repo.default_branch, &branches)) | |
| 1201 | 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 | 1267 | let (jar, chrome) = build_chrome(state, jar, viewer, uri.path().to_string()); | |
| @@ -2812,6 +2856,7 @@ async fn visible_repos( | |||
| 2812 | 2856 | } | |
| 2813 | 2857 | ||
| 2814 | 2858 | /// One row in a repository listing card. | |
| 2859 | + | #[derive(Clone)] | |
| 2815 | 2860 | pub(crate) struct RepoEntry { | |
| 2816 | 2861 | /// Link target. | |
| 2817 | 2862 | pub(crate) href: String, | |