collin/anvil · cc0e8932
feat: browse source at any branch, tag, or commit from the UI
Collin Richards · 2026-06-10 08:28 UTC · cc0e89329bb25691c739391ff11a79458fc87226 · parent c6b9364e · browse files
modifiedcrates/anvil-web/src/ui.rs+53 −9
| ⋯ 182 unchanged lines | |||
| 183 | 183 | .nav-dropdown { position:absolute; right:0; top:calc(100% + 6px); background:var(--bg); border:1px solid var(--border); border-radius:6px; min-width:130px; box-shadow:0 4px 14px rgba(0,0,0,.1); z-index:200; padding:4px 0; } | |
| 184 | 184 | .nav-dropdown a, .nav-dropdown button { display:block; width:100%; padding:6px 14px; font-size:13px; color:var(--fg); text-align:left; background:none; border:0; cursor:pointer; font:inherit; text-decoration:none; } | |
| 185 | 185 | .nav-dropdown a:hover, .nav-dropdown button:hover { background:var(--code-bg); color:var(--fg); } | |
| 186 | + | .nav-dropdown.left { left:0; right:auto; max-height:320px; overflow-y:auto; } | |
| 187 | + | .nav-dropdown .dd-head { padding:6px 14px 2px; font-size:11px; text-transform:uppercase; letter-spacing:.03em; color:var(--muted); } | |
| 188 | + | .nav-dropdown a.current { font-weight:600; } | |
| 189 | + | details.rev-menu { display:inline-block; } | |
| 190 | + | details.rev-menu > summary .pill { cursor:pointer; } | |
| 186 | 191 | "#; | |
| 187 | 192 | ||
| 188 | 193 | /// Clipboard icon for the clone "copy" button. | |
| ⋯ 672 unchanged lines | |||
| 861 | 866 | user.as_ref(), | |
| 862 | 867 | html! { | |
| 863 | 868 | (header) | |
| 864 | - | p.muted { | |
| 865 | - | "Branch: " (rev) " · " | |
| 866 | - | a href=(format!("/{owner}/{repo}/commits/{rev}")) { "commits" } | |
| 869 | + | p { | |
| 870 | + | (rev_switcher(&owner, &repo, &rev, &overview)) | |
| 871 | + | " · " | |
| 872 | + | a href=(format!("/{owner}/{repo}/commits/{}", enc_ref(&rev))) { "commits" } | |
| 867 | 873 | } | |
| 868 | 874 | @if let Some(c) = &latest { | |
| 869 | 875 | div.latest-commit { | |
| ⋯ 32 unchanged lines | |||
| 902 | 908 | path: &str, | |
| 903 | 909 | ) -> Result<Markup, Response> { | |
| 904 | 910 | let (repo_path, _) = resolve_repo(app, user.as_ref(), owner, repo).await?; | |
| 911 | + | let overview = browse::overview(&repo_path).map_err(server_error)?; | |
| 905 | 912 | let entries = browse::list_tree(&repo_path, rev, path).map_err(server_error)?; | |
| 906 | 913 | // Best-effort: a failed walk only costs the per-entry annotations. | |
| 907 | 914 | let entry_commits = | |
| ⋯ 3 unchanged lines | |||
| 911 | 918 | user.as_ref(), | |
| 912 | 919 | html! { | |
| 913 | 920 | h1 { a href=(format!("/{owner}/{repo}")) { (owner) "/" (repo) } } | |
| 921 | + | p { (rev_switcher(owner, repo, rev, &overview)) } | |
| 914 | 922 | (breadcrumbs(owner, repo, rev, path, false)) | |
| 915 | 923 | (tree_table(owner, repo, rev, path, &entries, &entry_commits)) | |
| 916 | 924 | }, | |
| ⋯ 37 unchanged lines | |||
| 954 | 962 | } | |
| 955 | 963 | }; | |
| 956 | 964 | ||
| 957 | - | let blob_url = format!("/{owner}/{repo}/blob/{rev}/{path}"); | |
| 965 | + | let blob_url = format!("/{owner}/{repo}/blob/{}/{path}", enc_ref(&rev)); | |
| 958 | 966 | Ok(layout( | |
| 959 | 967 | &format!("{owner}/{repo}: {path}"), | |
| 960 | 968 | user.as_ref(), | |
| ⋯ 85 unchanged lines | |||
| 1046 | 1054 | /// beyond this many commits just lose the annotation. | |
| 1047 | 1055 | const ENTRY_LOG_WALK: usize = 400; | |
| 1048 | 1056 | ||
| 1057 | + | /// Percent-encode a ref name for use as one path segment in a URL. Axum | |
| 1058 | + | /// matches routes before decoding, so an encoded `/` keeps a branch like | |
| 1059 | + | /// `feat/x` inside the single `{rev}` segment. | |
| 1060 | + | fn enc_ref(name: &str) -> String { | |
| 1061 | + | name.replace('%', "%25") | |
| 1062 | + | .replace('/', "%2F") | |
| 1063 | + | .replace('?', "%3F") | |
| 1064 | + | .replace('#', "%23") | |
| 1065 | + | } | |
| 1066 | + | ||
| 1067 | + | /// Branch/tag switcher: a dropdown over the current rev linking each ref to | |
| 1068 | + | /// its tree view. Branch names, tag names, and commit ids all work as `rev`. | |
| 1069 | + | fn rev_switcher(owner: &str, repo: &str, rev: &str, overview: &browse::Overview) -> Markup { | |
| 1070 | + | html! { | |
| 1071 | + | details.nav-menu.rev-menu { | |
| 1072 | + | summary { span.pill { (rev) } } | |
| 1073 | + | div.nav-dropdown.left { | |
| 1074 | + | @if !overview.branches.is_empty() { | |
| 1075 | + | div.dd-head { "Branches" } | |
| 1076 | + | @for b in &overview.branches { | |
| 1077 | + | a.current[b == rev] href=(format!("/{owner}/{repo}/tree/{}", enc_ref(b))) { (b) } | |
| 1078 | + | } | |
| 1079 | + | } | |
| 1080 | + | @if !overview.tags.is_empty() { | |
| 1081 | + | div.dd-head { "Tags" } | |
| 1082 | + | @for t in &overview.tags { | |
| 1083 | + | a.current[t == rev] href=(format!("/{owner}/{repo}/tree/{}", enc_ref(t))) { (t) } | |
| 1084 | + | } | |
| 1085 | + | } | |
| 1086 | + | } | |
| 1087 | + | } | |
| 1088 | + | } | |
| 1089 | + | } | |
| 1090 | + | ||
| 1049 | 1091 | /// Render a tree listing as a box of rows; directories link to `tree`, files to | |
| 1050 | 1092 | /// `blob`. Each entry also shows the subject of (and links to) the latest | |
| 1051 | 1093 | /// commit that touched it, when `latest` has one for it. | |
| ⋯ 23 unchanged lines | |||
| 1075 | 1117 | @let child = join(&e.name); | |
| 1076 | 1118 | @let kind = if e.is_dir { "tree" } else { "blob" }; | |
| 1077 | 1119 | div.row { | |
| 1078 | - | a.entry href=(format!("/{owner}/{repo}/{kind}/{rev}/{child}")) { | |
| 1120 | + | a.entry href=(format!("/{owner}/{repo}/{kind}/{}/{child}", enc_ref(rev))) { | |
| 1079 | 1121 | @if e.is_dir { | |
| 1080 | 1122 | span.icon.dir { (PreEscaped(FOLDER_SVG)) } | |
| 1081 | 1123 | } @else { | |
| ⋯ 13 unchanged lines | |||
| 1095 | 1137 | ||
| 1096 | 1138 | fn parent_link(owner: &str, repo: &str, rev: &str, path: &str) -> String { | |
| 1097 | 1139 | match path.rsplit_once('/') { | |
| 1098 | - | Some((parent, _)) => format!("/{owner}/{repo}/tree/{rev}/{parent}"), | |
| 1099 | - | None => format!("/{owner}/{repo}/tree/{rev}"), | |
| 1140 | + | Some((parent, _)) => format!("/{owner}/{repo}/tree/{}/{parent}", enc_ref(rev)), | |
| 1141 | + | None => format!("/{owner}/{repo}/tree/{}", enc_ref(rev)), | |
| 1100 | 1142 | } | |
| 1101 | 1143 | } | |
| 1102 | 1144 | ||
| ⋯ 12 unchanged lines | |||
| 1115 | 1157 | let last = crumbs.len(); | |
| 1116 | 1158 | html! { | |
| 1117 | 1159 | div.crumbs { | |
| 1118 | - | a href=(format!("/{owner}/{repo}/tree/{rev}")) { (rev) } | |
| 1160 | + | a href=(format!("/{owner}/{repo}/tree/{}", enc_ref(rev))) { (rev) } | |
| 1119 | 1161 | @for (i, (label, cum)) in crumbs.iter().enumerate() { | |
| 1120 | 1162 | " / " | |
| 1121 | 1163 | @if i + 1 == last && is_blob { | |
| 1122 | 1164 | span { (label) } | |
| 1123 | 1165 | } @else { | |
| 1124 | - | a href=(format!("/{owner}/{repo}/tree/{rev}/{cum}")) { (label) } | |
| 1166 | + | a href=(format!("/{owner}/{repo}/tree/{}/{cum}", enc_ref(rev))) { (label) } | |
| 1125 | 1167 | } | |
| 1126 | 1168 | } | |
| 1127 | 1169 | } | |
| ⋯ 62 unchanged lines | |||
| 1190 | 1232 | @if let Some(parent) = &detail.parent { | |
| 1191 | 1233 | " · parent " a.sha href=(format!("/{owner}/{repo}/commit/{parent}")) { (&parent[..parent.len().min(8)]) } | |
| 1192 | 1234 | } | |
| 1235 | + | " · " | |
| 1236 | + | a href=(format!("/{owner}/{repo}/tree/{}", detail.info.id)) { "browse files" } | |
| 1193 | 1237 | } | |
| 1194 | 1238 | @if detail.changes.is_empty() { | |
| 1195 | 1239 | p.muted { "No file changes." } | |
| ⋯ 382 unchanged lines | |||