anvilsign in

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
183183 .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; }
184184 .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; }
185185 .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; }
186191 "#;
187192
188193 /// Clipboard icon for the clone "copy" button.
⋯ 672 unchanged lines
861866 user.as_ref(),
862867 html! {
863868 (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" }
867873 }
868874 @if let Some(c) = &latest {
869875 div.latest-commit {
⋯ 32 unchanged lines
902908 path: &str,
903909 ) -> Result<Markup, Response> {
904910 let (repo_path, _) = resolve_repo(app, user.as_ref(), owner, repo).await?;
911+ let overview = browse::overview(&repo_path).map_err(server_error)?;
905912 let entries = browse::list_tree(&repo_path, rev, path).map_err(server_error)?;
906913 // Best-effort: a failed walk only costs the per-entry annotations.
907914 let entry_commits =
⋯ 3 unchanged lines
911918 user.as_ref(),
912919 html! {
913920 h1 { a href=(format!("/{owner}/{repo}")) { (owner) "/" (repo) } }
921+ p { (rev_switcher(owner, repo, rev, &overview)) }
914922 (breadcrumbs(owner, repo, rev, path, false))
915923 (tree_table(owner, repo, rev, path, &entries, &entry_commits))
916924 },
⋯ 37 unchanged lines
954962 }
955963 };
956964
957- let blob_url = format!("/{owner}/{repo}/blob/{rev}/{path}");
965+ let blob_url = format!("/{owner}/{repo}/blob/{}/{path}", enc_ref(&rev));
958966 Ok(layout(
959967 &format!("{owner}/{repo}: {path}"),
960968 user.as_ref(),
⋯ 85 unchanged lines
10461054 /// beyond this many commits just lose the annotation.
10471055 const ENTRY_LOG_WALK: usize = 400;
10481056
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+
10491091 /// Render a tree listing as a box of rows; directories link to `tree`, files to
10501092 /// `blob`. Each entry also shows the subject of (and links to) the latest
10511093 /// commit that touched it, when `latest` has one for it.
⋯ 23 unchanged lines
10751117 @let child = join(&e.name);
10761118 @let kind = if e.is_dir { "tree" } else { "blob" };
10771119 div.row {
1078- a.entry href=(format!("/{owner}/{repo}/{kind}/{rev}/{child}")) {
1120+ a.entry href=(format!("/{owner}/{repo}/{kind}/{}/{child}", enc_ref(rev))) {
10791121 @if e.is_dir {
10801122 span.icon.dir { (PreEscaped(FOLDER_SVG)) }
10811123 } @else {
⋯ 13 unchanged lines
10951137
10961138 fn parent_link(owner: &str, repo: &str, rev: &str, path: &str) -> String {
10971139 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)),
11001142 }
11011143 }
11021144
⋯ 12 unchanged lines
11151157 let last = crumbs.len();
11161158 html! {
11171159 div.crumbs {
1118- a href=(format!("/{owner}/{repo}/tree/{rev}")) { (rev) }
1160+ a href=(format!("/{owner}/{repo}/tree/{}", enc_ref(rev))) { (rev) }
11191161 @for (i, (label, cum)) in crumbs.iter().enumerate() {
11201162 " / "
11211163 @if i + 1 == last && is_blob {
11221164 span { (label) }
11231165 } @else {
1124- a href=(format!("/{owner}/{repo}/tree/{rev}/{cum}")) { (label) }
1166+ a href=(format!("/{owner}/{repo}/tree/{}/{cum}", enc_ref(rev))) { (label) }
11251167 }
11261168 }
11271169 }
⋯ 62 unchanged lines
11901232 @if let Some(parent) = &detail.parent {
11911233 " · parent " a.sha href=(format!("/{owner}/{repo}/commit/{parent}")) { (&parent[..parent.len().min(8)]) }
11921234 }
1235+ " · "
1236+ a href=(format!("/{owner}/{repo}/tree/{}", detail.info.id)) { "browse files" }
11931237 }
11941238 @if detail.changes.is_empty() {
11951239 p.muted { "No file changes." }
⋯ 382 unchanged lines