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