| 11 | | 11 | |
| 12 | | 12 | |
| 13 | | 13 | |
| | 14 | use std::collections::HashMap; |
| 14 | use std::path::Path as FsPath; | 15 | use std::path::Path as FsPath; |
| 15 | | 16 | |
| 16 | use axum::extract::{Path, Query, State}; | 17 | use axum::extract::{Path, Query, State}; |
| 58 | #[derive(Clone, Copy, PartialEq, Eq)] | 59 | #[derive(Clone, Copy, PartialEq, Eq)] |
| 59 | enum Tab { | 60 | enum Tab { |
| 60 | Code, | 61 | Code, |
| | 62 | Commits, |
| 61 | Branches, | 63 | Branches, |
| 62 | Tags, | 64 | Tags, |
| 63 | Search, | 65 | Search, |
| 92 | .map_err(AppError::from) | 94 | .map_err(AppError::from) |
| 93 | } | 95 | } |
| 94 | | 96 | |
| | 97 | |
| | 98 | |
| | 99 | async fn branch_names(state: &AppState, repo_id: &str) -> Vec<String> { |
| | 100 | git_read(state, repo_id, git::Repo::branch_names) |
| | 101 | .await |
| | 102 | .unwrap_or_default() |
| | 103 | } |
| | 104 | |
| 95 | | 105 | |
| 96 | | 106 | |
| 97 | | 107 | |
| 267 | "branches" => branches_view(state, viewer, jar, uri, ctx).await, | 277 | "branches" => branches_view(state, viewer, jar, uri, ctx).await, |
| 268 | "tags" => tags_view(state, viewer, jar, uri, ctx).await, | 278 | "tags" => tags_view(state, viewer, jar, uri, ctx).await, |
| 269 | "search" => { | 279 | "search" => { |
| 270 | let header = repo_header(state, &ctx, Tab::Search, &ctx.repo.default_branch); | 280 | let branches = branch_names(state, &ctx.repo.id).await; |
| | 281 | let header = repo_header( |
| | 282 | state, |
| | 283 | &ctx, |
| | 284 | Tab::Search, |
| | 285 | &ctx.repo.default_branch, |
| | 286 | &branches, |
| | 287 | ); |
| 271 | let raw_query = dq.q.clone().unwrap_or_default(); | 288 | let raw_query = dq.q.clone().unwrap_or_default(); |
| 272 | crate::search::in_repo(state, viewer, jar, uri, &ctx, &raw_query, header).await | 289 | crate::search::in_repo(state, viewer, jar, uri, &ctx, &raw_query, header).await |
| 273 | } | 290 | } |
| 322 | }) | 339 | }) |
| 323 | .await?; | 340 | .await?; |
| 324 | let readme = load_readme(state, &ctx.repo.id, &rev, &entries).await; | 341 | let readme = load_readme(state, &ctx.repo.id, &rev, &entries).await; |
| | 342 | let branches = branch_names(state, &ctx.repo.id).await; |
| 325 | let base = format!("/{}/{}", ctx.owner.username, ctx.repo.path); | 343 | let base = format!("/{}/{}", ctx.owner.username, ctx.repo.path); |
| 326 | html! { | 344 | html! { |
| 327 | (repo_header(state, &ctx, Tab::Code, &rev_name)) | 345 | (repo_header(state, &ctx, Tab::Code, &rev_name, &branches)) |
| 328 | form class="search-row repo-codesearch" method="get" action=(format!("{base}/-/search")) { | 346 | form class="search-row repo-codesearch" method="get" action=(format!("{base}/-/search")) { |
| 329 | input type="search" name="q" placeholder="Search this repository…" | 347 | input type="search" name="q" placeholder="Search this repository…" |
| 330 | aria-label="Search this repository"; | 348 | aria-label="Search this repository"; |
| 373 | | 391 | |
| 374 | fn empty_repo_body(state: &AppState, ctx: &RepoCtx) -> Markup { | 392 | fn empty_repo_body(state: &AppState, ctx: &RepoCtx) -> Markup { |
| 375 | html! { | 393 | html! { |
| 376 | (repo_header(state, ctx, Tab::Code, &ctx.repo.default_branch)) | 394 | (repo_header(state, ctx, Tab::Code, &ctx.repo.default_branch, &[])) |
| 377 | div class="card" { | 395 | div class="card" { |
| 378 | h2 { "This repository is empty" } | 396 | h2 { "This repository is empty" } |
| 379 | p class="muted" { "Push a commit to get started:" } | 397 | p class="muted" { "Push a commit to get started:" } |
| 408 | }) | 426 | }) |
| 409 | .await?; | 427 | .await?; |
| 410 | | 428 | |
| | 429 | let branches = branch_names(state, &ctx.repo.id).await; |
| 411 | let body = html! { | 430 | let body = html! { |
| 412 | (repo_header(state, &ctx, Tab::Code, &rev_name)) | 431 | (repo_header(state, &ctx, Tab::Code, &rev_name, &branches)) |
| 413 | (breadcrumb(&ctx.owner.username, &ctx.repo.path, &rev_name, &path)) | 432 | (breadcrumb(&ctx.owner.username, &ctx.repo.path, &rev_name, &path)) |
| 414 | (tree_table(&ctx.owner.username, &ctx.repo.path, &rev_name, &path, &entries)) | 433 | (tree_table(&ctx.owner.username, &ctx.repo.path, &rev_name, &path, &entries)) |
| 415 | }; | 434 | }; |
| 462 | attr_lang.as_deref(), | 481 | attr_lang.as_deref(), |
| 463 | source_view, | 482 | source_view, |
| 464 | ); | 483 | ); |
| | 484 | let branches = branch_names(state, &ctx.repo.id).await; |
| 465 | let body = html! { | 485 | let body = html! { |
| 466 | (repo_header(state, &ctx, Tab::Code, &rev_name)) | 486 | (repo_header(state, &ctx, Tab::Code, &rev_name, &branches)) |
| 467 | (breadcrumb(&ctx.owner.username, &ctx.repo.path, &rev_name, &path)) | 487 | (breadcrumb(&ctx.owner.username, &ctx.repo.path, &rev_name, &path)) |
| 468 | (content_body) | 488 | (content_body) |
| 469 | }; | 489 | }; |
| 610 | ctx: RepoCtx, | 630 | ctx: RepoCtx, |
| 611 | ) -> AppResult<Response> { | 631 | ) -> AppResult<Response> { |
| 612 | let branches = git_read(state, &ctx.repo.id, git::Repo::branches).await?; | 632 | let branches = git_read(state, &ctx.repo.id, git::Repo::branches).await?; |
| | 633 | let branch_list: Vec<String> = branches.iter().map(|b| b.name.clone()).collect(); |
| 613 | let body = html! { | 634 | let body = html! { |
| 614 | (repo_header(state, &ctx, Tab::Branches, &ctx.repo.default_branch)) | 635 | (repo_header(state, &ctx, Tab::Branches, &ctx.repo.default_branch, &branch_list)) |
| 615 | h2 { "Branches" } | 636 | h2 { "Branches" } |
| 616 | table class="list" { | 637 | table class="list" { |
| 617 | thead { tr { th { "Branch" } th { "Ahead" } th { "Behind" } th { "Updated" } } } | 638 | thead { tr { th { "Branch" } th { "Ahead" } th { "Behind" } th { "Updated" } } } |
| 645 | ctx: RepoCtx, | 666 | ctx: RepoCtx, |
| 646 | ) -> AppResult<Response> { | 667 | ) -> AppResult<Response> { |
| 647 | let tags = git_read(state, &ctx.repo.id, git::Repo::tags).await?; | 668 | let tags = git_read(state, &ctx.repo.id, git::Repo::tags).await?; |
| | 669 | let branches = branch_names(state, &ctx.repo.id).await; |
| 648 | let body = html! { | 670 | let body = html! { |
| 649 | (repo_header(state, &ctx, Tab::Tags, &ctx.repo.default_branch)) | 671 | (repo_header(state, &ctx, Tab::Tags, &ctx.repo.default_branch, &branches)) |
| 650 | h2 { "Tags" } | 672 | h2 { "Tags" } |
| 651 | table class="list" { | 673 | table class="list" { |
| 652 | thead { tr { th { "Tag" } th { "Commit" } th { "Kind" } } } | 674 | thead { tr { th { "Tag" } th { "Commit" } th { "Kind" } } } |
| 703 | }) | 725 | }) |
| 704 | .await?; | 726 | .await?; |
| 705 | | 727 | |
| | 728 | |
| | 729 | let mut signers: HashMap<String, String> = HashMap::new(); |
| | 730 | for s in &states { |
| | 731 | if let git::SignatureState::Verified { user_id, .. } = s |
| | 732 | && !signers.contains_key(user_id) |
| | 733 | && let Ok(Some(u)) = state.store.user_by_id(user_id).await |
| | 734 | { |
| | 735 | signers.insert(user_id.clone(), u.username); |
| | 736 | } |
| | 737 | } |
| | 738 | |
| 706 | let base = format!("/{}/{}", ctx.owner.username, ctx.repo.path); | 739 | let base = format!("/{}/{}", ctx.owner.username, ctx.repo.path); |
| 707 | let now = crate::now_ms(); | 740 | let now = crate::now_ms(); |
| | 741 | let branches = branch_names(state, &ctx.repo.id).await; |
| 708 | let body = html! { | 742 | let body = html! { |
| 709 | (repo_header(state, &ctx, Tab::Code, &rev_name)) | 743 | (repo_header(state, &ctx, Tab::Commits, &rev_name, &branches)) |
| 710 | h2 { "Commits" } | 744 | h2 { "Commits" } |
| 711 | ul class="commit-list card" { | 745 | ul class="commit-list card" { |
| 712 | @for (commit, sig) in commits.iter().zip(states.iter()) { | 746 | @for (commit, sig) in commits.iter().zip(states.iter()) { |
| 713 | @let commit_url = format!("{base}/-/commit/{}", commit.oid); | 747 | @let commit_url = format!("{base}/-/commit/{}", commit.oid); |
| | 748 | @let signer = match sig { |
| | 749 | git::SignatureState::Verified { user_id, .. } => signers.get(user_id).map(String::as_str), |
| | 750 | _ => None, |
| | 751 | }; |
| 714 | li class="commit-row" { | 752 | li class="commit-row" { |
| 715 | div class="commit-row-body" { | 753 | div class="commit-row-body" { |
| 716 | a class="commit-row-msg" href=(commit_url) { (commit.summary) } | 754 | a class="commit-row-msg" href=(commit_url) { (commit.summary) } |
| 721 | } | 759 | } |
| 722 | } | 760 | } |
| 723 | div class="commit-row-actions" { | 761 | div class="commit-row-actions" { |
| 724 | (signature_badge(sig)) | 762 | (commit_sig_cell(sig, signer, commit.author.time_ms)) |
| 725 | a class="mono commit-sha-pill" href=(commit_url) { (commit.oid.short()) } | 763 | a class="mono commit-sha-pill" href=(commit_url) { (commit.oid.short()) } |
| 726 | button class="icon-btn commit-copy" type="button" | 764 | button class="icon-btn commit-copy" type="button" |
| 727 | data-clipboard=(commit.oid.to_string()) aria-label="Copy commit id" { | 765 | data-clipboard=(commit.oid.to_string()) aria-label="Copy commit id" { |
| 774 | (a + fa, d + fd) | 812 | (a + fa, d + fd) |
| 775 | }); | 813 | }); |
| 776 | let repo_base = format!("/{}/{}", ctx.owner.username, ctx.repo.path); | 814 | let repo_base = format!("/{}/{}", ctx.owner.username, ctx.repo.path); |
| | 815 | let branches = branch_names(state, &ctx.repo.id).await; |
| 777 | | 816 | |
| 778 | let body = html! { | 817 | let body = html! { |
| 779 | (repo_header(state, &ctx, Tab::Code, &ctx.repo.default_branch)) | 818 | (repo_header(state, &ctx, Tab::Code, &ctx.repo.default_branch, &branches)) |
| 780 | (commit_meta(&detail, &sig)) | 819 | (commit_meta(&detail)) |
| 781 | (signature_detail(&sig, signer.as_deref())) | 820 | (signature_detail(&sig, signer.as_deref())) |
| 782 | div class="diff-toolbar" { | 821 | div class="diff-toolbar" { |
| 783 | span { | 822 | span { |
| 910 | } | 949 | } |
| 911 | } | 950 | } |
| 912 | | 951 | |
| 913 | | 952 | |
| 914 | fn commit_meta(detail: &git::CommitDetail, sig: &git::SignatureState) -> Markup { | 953 | |
| | 954 | fn commit_meta(detail: &git::CommitDetail) -> Markup { |
| 915 | let (subject, body) = detail | 955 | let (subject, body) = detail |
| 916 | .message | 956 | .message |
| 917 | .split_once("\n\n") | 957 | .split_once("\n\n") |
| 918 | .map_or((detail.message.as_str(), ""), |(s, b)| (s.trim(), b)); | 958 | .map_or((detail.message.as_str(), ""), |(s, b)| (s.trim(), b)); |
| 919 | html! { | 959 | html! { |
| 920 | div class="commit-meta" { | 960 | div class="commit-meta" { |
| 921 | h2 { (subject.lines().next().unwrap_or("")) " " (signature_badge(sig)) } | 961 | div class="commit-title-row" { |
| 922 | p class="muted" { | 962 | h2 class="commit-title" { (subject.lines().next().unwrap_or("")) } |
| 923 | span class="mono" { (detail.oid.short()) } | 963 | p class="commit-title-meta muted" { |
| 924 | " · " (detail.author.name) | 964 | span class="mono" { (detail.oid.short()) } |
| 925 | " committed on " (fmt_date(detail.committer.time_ms)) | 965 | " · " (detail.author.name) |
| | 966 | " committed on " (fmt_date(detail.committer.time_ms)) |
| | 967 | } |
| 926 | } | 968 | } |
| 927 | @if !body.trim().is_empty() { | 969 | @if !body.trim().is_empty() { |
| 928 | pre class="commit-body" { (body.trim_end()) } | 970 | pre class="commit-body" { (body.trim_end()) } |
| 1240 | } | 1282 | } |
| 1241 | | 1283 | |
| 1242 | | 1284 | |
| 1243 | | 1285 | |
| 1244 | fn repo_header(state: &AppState, ctx: &RepoCtx, active: Tab, rev: &str) -> Markup { | 1286 | fn repo_header( |
| | 1287 | state: &AppState, |
| | 1288 | ctx: &RepoCtx, |
| | 1289 | active: Tab, |
| | 1290 | rev: &str, |
| | 1291 | branches: &[String], |
| | 1292 | ) -> Markup { |
| 1245 | let base = format!("/{}/{}", ctx.owner.username, ctx.repo.path); | 1293 | let base = format!("/{}/{}", ctx.owner.username, ctx.repo.path); |
| 1246 | let tab = |t: Tab, label: &str, glyph: Icon, href: String| { | 1294 | let tab = |t: Tab, label: &str, glyph: Icon, href: String| { |
| 1247 | html! { | 1295 | html! { |
| 1262 | div class="repo-nav" { | 1310 | div class="repo-nav" { |
| 1263 | nav class="tabs repo-tabs" { | 1311 | nav class="tabs repo-tabs" { |
| 1264 | (tab(Tab::Code, "Code", Icon::File, base.clone())) | 1312 | (tab(Tab::Code, "Code", Icon::File, base.clone())) |
| | 1313 | (tab(Tab::Commits, "Commits", Icon::History, format!("{base}/-/commits/{rev}"))) |
| 1265 | (tab(Tab::Branches, "Branches", Icon::GitBranch, format!("{base}/-/branches"))) | 1314 | (tab(Tab::Branches, "Branches", Icon::GitBranch, format!("{base}/-/branches"))) |
| 1266 | (tab(Tab::Tags, "Tags", Icon::Box, format!("{base}/-/tags"))) | 1315 | (tab(Tab::Tags, "Tags", Icon::Box, format!("{base}/-/tags"))) |
| 1267 | } | 1316 | } |
| 1268 | div class="repo-actions" { | 1317 | div class="repo-actions" { |
| 1269 | a class="btn" href=(format!("{base}/-/branches")) { | 1318 | (branch_menu(&base, rev, branches)) |
| 1270 | (icon(Icon::GitBranch)) span { (rev) } | | |
| 1271 | } | | |
| 1272 | a class="btn" href=(format!("{base}/-/commits/{rev}")) { "Commits" } | | |
| 1273 | (clone_menu(state, ctx)) | 1319 | (clone_menu(state, ctx)) |
| 1274 | } | 1320 | } |
| 1275 | } | 1321 | } |
| 1277 | } | 1323 | } |
| 1278 | } | 1324 | } |
| 1279 | | 1325 | |
| | 1326 | |
| | 1327 | |
| | 1328 | fn branch_menu(base: &str, rev: &str, branches: &[String]) -> Markup { |
| | 1329 | html! { |
| | 1330 | @if branches.is_empty() { |
| | 1331 | a class="btn" href=(format!("{base}/-/branches")) { |
| | 1332 | (icon(Icon::GitBranch)) span { (rev) } |
| | 1333 | } |
| | 1334 | } @else { |
| | 1335 | details class="branch-menu" { |
| | 1336 | summary class="btn" { (icon(Icon::GitBranch)) span { (rev) } (icon(Icon::ChevronDown)) } |
| | 1337 | div class="branch-pop card" { |
| | 1338 | div class="branch-pop-head muted" { "Switch branch" } |
| | 1339 | @for name in branches { |
| | 1340 | a href=(format!("{base}/-/tree/{name}")) |
| | 1341 | aria-current=[(name == rev).then_some("page")] { (name) } |
| | 1342 | } |
| | 1343 | a class="branch-pop-all" href=(format!("{base}/-/branches")) { "View all branches" } |
| | 1344 | } |
| | 1345 | } |
| | 1346 | } |
| | 1347 | } |
| | 1348 | } |
| | 1349 | |
| 1280 | | 1350 | |
| 1281 | fn clone_menu(state: &AppState, ctx: &RepoCtx) -> Markup { | 1351 | fn clone_menu(state: &AppState, ctx: &RepoCtx) -> Markup { |
| 1282 | let https = clone_https(state, ctx); | 1352 | let https = clone_https(state, ctx); |
| 1406 | } | 1476 | } |
| 1407 | } | 1477 | } |
| 1408 | | 1478 | |
| | 1479 | |
| | 1480 | fn key_kind_label(kind: model::KeyKind) -> &'static str { |
| | 1481 | match kind { |
| | 1482 | model::KeyKind::Ssh => "SSH", |
| | 1483 | model::KeyKind::Gpg => "GPG", |
| | 1484 | } |
| | 1485 | } |
| | 1486 | |
| | 1487 | |
| | 1488 | |
| | 1489 | |
| | 1490 | fn commit_sig_cell(state: &git::SignatureState, signer: Option<&str>, time_ms: i64) -> Markup { |
| | 1491 | match state { |
| | 1492 | git::SignatureState::Verified { |
| | 1493 | kind, |
| | 1494 | fingerprint, |
| | 1495 | user_id, |
| | 1496 | .. |
| | 1497 | } => html! { |
| | 1498 | span class="sig-pop-wrap" tabindex="0" { |
| | 1499 | (signature_badge(state)) |
| | 1500 | div class="sig-pop card" { |
| | 1501 | div class="sig-pop-title" { |
| | 1502 | "This commit was signed with a verified signature." |
| | 1503 | } |
| | 1504 | div class="sig-pop-signer" { |
| | 1505 | "Signed by " strong { (signer.unwrap_or(user_id)) } |
| | 1506 | } |
| | 1507 | div class="sig-pop-fp" { |
| | 1508 | (key_kind_label(*kind)) " key fingerprint:" |
| | 1509 | span class="mono" { (fingerprint) } |
| | 1510 | } |
| | 1511 | div class="muted sig-pop-when" { "Verified on " (fmt_joined(time_ms)) } |
| | 1512 | } |
| | 1513 | } |
| | 1514 | }, |
| | 1515 | _ => signature_badge(state), |
| | 1516 | } |
| | 1517 | } |
| | 1518 | |
| 1409 | | 1519 | |
| 1410 | fn signature_badge(state: &git::SignatureState) -> Markup { | 1520 | fn signature_badge(state: &git::SignatureState) -> Markup { |
| 1411 | match state { | 1521 | match state { |
| 1429 | | 1539 | |
| 1430 | | 1540 | |
| 1431 | fn signature_detail(state: &git::SignatureState, signer: Option<&str>) -> Markup { | 1541 | fn signature_detail(state: &git::SignatureState, signer: Option<&str>) -> Markup { |
| 1432 | let kind_label = |k: model::KeyKind| match k { | | |
| 1433 | model::KeyKind::Ssh => "SSH", | | |
| 1434 | model::KeyKind::Gpg => "GPG", | | |
| 1435 | }; | | |
| 1436 | match state { | 1542 | match state { |
| 1437 | git::SignatureState::Unsigned => html! {}, | 1543 | git::SignatureState::Unsigned => html! {}, |
| 1438 | git::SignatureState::Verified { | 1544 | git::SignatureState::Verified { |
| 1446 | "Signed by " strong { (signer.unwrap_or(user_id)) } | 1552 | "Signed by " strong { (signer.unwrap_or(user_id)) } |
| 1447 | } | 1553 | } |
| 1448 | span class="sig-fp mono" { | 1554 | span class="sig-fp mono" { |
| 1449 | (kind_label(*kind)) " key fingerprint: " (fingerprint) | 1555 | (key_kind_label(*kind)) " key fingerprint: " (fingerprint) |
| 1450 | } | 1556 | } |
| 1451 | } | 1557 | } |
| 1452 | }, | 1558 | }, |
| 1453 | git::SignatureState::UnknownKey { kind, fingerprint } => html! { | 1559 | git::SignatureState::UnknownKey { kind, fingerprint } => html! { |
| 1454 | div class="sig-detail sig-unverified" { | 1560 | div class="sig-detail sig-unverified" { |
| 1455 | span { "Signed by an unregistered " (kind_label(*kind)) " key" } | 1561 | span { "Signed by an unregistered " (key_kind_label(*kind)) " key" } |
| 1456 | span class="sig-fp mono" { (fingerprint) } | 1562 | span class="sig-fp mono" { (fingerprint) } |
| 1457 | } | 1563 | } |
| 1458 | }, | 1564 | }, |