collin/anvil · c4800e5f
feat: per-file latest-commit annotations in the tree viewer
Collin Richards · 2026-06-09 22:54 UTC · c4800e5f6e753c3893c58f58bd32f9cdd5fe2a82 · parent 39b66ff9 · browse files
modifiedCargo.lock+1 −0
| ⋯ 174 unchanged lines | |||
| 175 | 175 | "anvil-core", | |
| 176 | 176 | "gitserver-core", | |
| 177 | 177 | "gix", | |
| 178 | + | "tempfile", | |
| 178 | 179 | "thiserror", | |
| 179 | 180 | "toasty", | |
| 180 | 181 | "tokio", | |
| ⋯ 5059 unchanged lines | |||
modifiedcrates/anvil-git/Cargo.toml+3 −0
| ⋯ 14 unchanged lines | |||
| 15 | 15 | tokio.workspace = true | |
| 16 | 16 | thiserror.workspace = true | |
| 17 | 17 | tracing.workspace = true | |
| 18 | + | ||
| 19 | + | [dev-dependencies] | |
| 20 | + | tempfile = "3" | |
modifiedcrates/anvil-git/src/browse.rs+133 −0
| ⋯ 100 unchanged lines | |||
| 101 | 101 | } | |
| 102 | 102 | ||
| 103 | 103 | /// Metadata about a single commit. | |
| 104 | + | #[derive(Clone)] | |
| 104 | 105 | pub struct CommitInfo { | |
| 105 | 106 | pub id: String, | |
| 106 | 107 | pub short: String, | |
| ⋯ 27 unchanged lines | |||
| 134 | 135 | pub changes: Vec<FileChange>, | |
| 135 | 136 | } | |
| 136 | 137 | ||
| 138 | + | /// For each immediate entry of the tree at `path` (empty for root) in `rev`, | |
| 139 | + | /// find the most recent commit that changed it: the newest commit, walking | |
| 140 | + | /// first-parent history up to `limit` commits, where the entry's oid differs | |
| 141 | + | /// from (or is absent in) the parent. Returns entry name → commit. Entries | |
| 142 | + | /// last touched beyond the walk window are absent (callers omit the | |
| 143 | + | /// annotation). On a merge commit, side-branch changes attribute to the merge | |
| 144 | + | /// itself — acceptable for the linear histories this targets. | |
| 145 | + | pub fn latest_entry_commits( | |
| 146 | + | repo_path: &Path, | |
| 147 | + | rev: &str, | |
| 148 | + | path: &str, | |
| 149 | + | limit: usize, | |
| 150 | + | ) -> Result<BTreeMap<String, CommitInfo>> { | |
| 151 | + | let repo = gix::open(repo_path).map_err(read)?; | |
| 152 | + | let mut commit = repo | |
| 153 | + | .rev_parse_single(rev) | |
| 154 | + | .map_err(read)? | |
| 155 | + | .object() | |
| 156 | + | .map_err(read)? | |
| 157 | + | .peel_to_commit() | |
| 158 | + | .map_err(read)?; | |
| 159 | + | ||
| 160 | + | // Entries as of `rev`. While an entry's oid matches the parent's it is | |
| 161 | + | // unchanged, so carrying the original oid through the walk stays correct. | |
| 162 | + | let mut remaining = entries_at(&commit, path)?; | |
| 163 | + | let mut out = BTreeMap::new(); | |
| 164 | + | ||
| 165 | + | for _ in 0..limit { | |
| 166 | + | if remaining.is_empty() { | |
| 167 | + | break; | |
| 168 | + | } | |
| 169 | + | let parent = match commit.parent_ids().next() { | |
| 170 | + | Some(p) => Some( | |
| 171 | + | repo.find_object(p.detach()) | |
| 172 | + | .map_err(read)? | |
| 173 | + | .try_into_commit() | |
| 174 | + | .map_err(read)?, | |
| 175 | + | ), | |
| 176 | + | None => None, | |
| 177 | + | }; | |
| 178 | + | let parent_entries = match &parent { | |
| 179 | + | Some(p) => entries_at(p, path)?, | |
| 180 | + | None => BTreeMap::new(), // root commit: everything left was added here | |
| 181 | + | }; | |
| 182 | + | let info = commit_info(&commit)?; | |
| 183 | + | remaining.retain(|name, oid| { | |
| 184 | + | if parent_entries.get(name) == Some(oid) { | |
| 185 | + | return true; | |
| 186 | + | } | |
| 187 | + | out.insert(name.clone(), info.clone()); | |
| 188 | + | false | |
| 189 | + | }); | |
| 190 | + | match parent { | |
| 191 | + | Some(p) => commit = p, | |
| 192 | + | None => break, | |
| 193 | + | } | |
| 194 | + | } | |
| 195 | + | Ok(out) | |
| 196 | + | } | |
| 197 | + | ||
| 198 | + | /// Immediate entries (name → oid) of the tree at `path` within `commit`; | |
| 199 | + | /// empty if `path` is not a tree in that commit. | |
| 200 | + | fn entries_at(commit: &gix::Commit, path: &str) -> Result<BTreeMap<String, gix::ObjectId>> { | |
| 201 | + | let root = commit.tree().map_err(read)?; | |
| 202 | + | let tree = if path.is_empty() { | |
| 203 | + | root | |
| 204 | + | } else { | |
| 205 | + | match root.lookup_entry_by_path(path).map_err(read)? { | |
| 206 | + | Some(e) if e.mode().is_tree() => { | |
| 207 | + | e.object().map_err(read)?.peel_to_tree().map_err(read)? | |
| 208 | + | } | |
| 209 | + | _ => return Ok(BTreeMap::new()), | |
| 210 | + | } | |
| 211 | + | }; | |
| 212 | + | let mut out = BTreeMap::new(); | |
| 213 | + | for e in tree.iter() { | |
| 214 | + | let e = e.map_err(read)?; | |
| 215 | + | out.insert(e.filename().to_string(), e.oid().to_owned()); | |
| 216 | + | } | |
| 217 | + | Ok(out) | |
| 218 | + | } | |
| 219 | + | ||
| 137 | 220 | /// Walk commit history starting at `rev`, newest first, up to `limit` commits. | |
| 138 | 221 | pub fn commit_log(repo_path: &Path, rev: &str, limit: usize) -> Result<Vec<CommitInfo>> { | |
| 139 | 222 | let repo = gix::open(repo_path).map_err(read)?; | |
| ⋯ 212 unchanged lines | |||
| 352 | 435 | _ => Ok(None), | |
| 353 | 436 | } | |
| 354 | 437 | } | |
| 438 | + | ||
| 439 | + | #[cfg(test)] | |
| 440 | + | mod tests { | |
| 441 | + | use super::*; | |
| 442 | + | ||
| 443 | + | fn git(dir: &Path, args: &[&str]) { | |
| 444 | + | let out = std::process::Command::new("git") | |
| 445 | + | .args(args) | |
| 446 | + | .current_dir(dir) | |
| 447 | + | .env("GIT_AUTHOR_NAME", "t") | |
| 448 | + | .env("GIT_AUTHOR_EMAIL", "t@example.com") | |
| 449 | + | .env("GIT_COMMITTER_NAME", "t") | |
| 450 | + | .env("GIT_COMMITTER_EMAIL", "t@example.com") | |
| 451 | + | .output() | |
| 452 | + | .expect("run git"); | |
| 453 | + | assert!(out.status.success(), "git {args:?}: {:?}", out); | |
| 454 | + | } | |
| 455 | + | ||
| 456 | + | #[test] | |
| 457 | + | fn latest_entry_commits_attributes_each_entry() { | |
| 458 | + | let tmp = tempfile::tempdir().unwrap(); | |
| 459 | + | let dir = tmp.path(); | |
| 460 | + | git(dir, &["init", "-q", "-b", "main"]); | |
| 461 | + | ||
| 462 | + | std::fs::write(dir.join("a.txt"), "one").unwrap(); | |
| 463 | + | std::fs::create_dir(dir.join("sub")).unwrap(); | |
| 464 | + | std::fs::write(dir.join("sub/b.txt"), "b").unwrap(); | |
| 465 | + | git(dir, &["add", "."]); | |
| 466 | + | git(dir, &["commit", "-qm", "first"]); | |
| 467 | + | ||
| 468 | + | std::fs::write(dir.join("a.txt"), "two").unwrap(); | |
| 469 | + | git(dir, &["commit", "-qam", "second: touch a.txt"]); | |
| 470 | + | ||
| 471 | + | let latest = latest_entry_commits(dir, "main", "", 100).unwrap(); | |
| 472 | + | assert_eq!(latest["a.txt"].summary, "second: touch a.txt"); | |
| 473 | + | assert_eq!( | |
| 474 | + | latest["sub"].summary, "first", | |
| 475 | + | "untouched dir keeps its original commit" | |
| 476 | + | ); | |
| 477 | + | ||
| 478 | + | // Within the subdirectory too. | |
| 479 | + | let latest = latest_entry_commits(dir, "main", "sub", 100).unwrap(); | |
| 480 | + | assert_eq!(latest["b.txt"].summary, "first"); | |
| 481 | + | ||
| 482 | + | // A walk window of 1 only attributes what the head commit changed. | |
| 483 | + | let latest = latest_entry_commits(dir, "main", "", 1).unwrap(); | |
| 484 | + | assert!(latest.contains_key("a.txt")); | |
| 485 | + | assert!(!latest.contains_key("sub")); | |
| 486 | + | } | |
| 487 | + | } | |
modifiedcrates/anvil-web/src/ui.rs+33 −5
| ⋯ 1 unchanged line | |||
| 2 | 2 | //! blob viewing. Pages are plain SSR and work without JavaScript; htmx-based | |
| 3 | 3 | //! progressive enhancement is a follow-up. | |
| 4 | 4 | ||
| 5 | - | use std::collections::HashMap; | |
| 5 | + | use std::collections::{BTreeMap, HashMap}; | |
| 6 | 6 | use std::path::PathBuf; | |
| 7 | 7 | use std::sync::OnceLock; | |
| 8 | 8 | ||
| ⋯ 34 unchanged lines | |||
| 43 | 43 | .box { border:1px solid var(--border); border-radius:6px; overflow:hidden; } | |
| 44 | 44 | .box .row { display:flex; justify-content:space-between; padding:8px 16px; border-top:1px solid var(--border); } | |
| 45 | 45 | .box .row:first-child { border-top:0; } | |
| 46 | - | .box .row a.entry { display:flex; gap:8px; align-items:center; } | |
| 46 | + | .box .row a.entry { display:flex; gap:8px; align-items:center; white-space:nowrap; } | |
| 47 | + | .box .row a.fc-msg { flex:1; margin-left:24px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; text-align:left; color:var(--muted); font-size:13px; } | |
| 48 | + | .box .row a.fc-msg:hover { color:var(--accent); } | |
| 49 | + | .box .row .fc-time { margin-left:16px; white-space:nowrap; color:var(--muted); font-size:13px; } | |
| 47 | 50 | .icon { width:16px; color:var(--muted); } | |
| 48 | 51 | table.code { border-collapse:collapse; width:100%; font:12px/1.45 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; } | |
| 49 | 52 | table.code td { padding:0 10px; vertical-align:top; white-space:pre; } | |
| ⋯ 691 unchanged lines | |||
| 741 | 744 | .map_err(server_error)? | |
| 742 | 745 | .into_iter() | |
| 743 | 746 | .next(); | |
| 747 | + | // Best-effort: a failed walk only costs the per-entry annotations. | |
| 748 | + | let entry_commits = | |
| 749 | + | browse::latest_entry_commits(&path, &rev, "", ENTRY_LOG_WALK).unwrap_or_default(); | |
| 744 | 750 | ||
| 745 | 751 | Ok(layout( | |
| 746 | 752 | &format!("{owner}/{repo}"), | |
| ⋯ 11 unchanged lines | |||
| 758 | 764 | span.muted style="margin-left:auto" { (c.author) " · " (fmt_time(c.time)) } | |
| 759 | 765 | } | |
| 760 | 766 | } | |
| 761 | - | (tree_table(&owner, &repo, &rev, "", &entries)) | |
| 767 | + | (tree_table(&owner, &repo, &rev, "", &entries, &entry_commits)) | |
| 762 | 768 | }, | |
| 763 | 769 | )) | |
| 764 | 770 | } | |
| ⋯ 24 unchanged lines | |||
| 789 | 795 | ) -> Result<Markup, Response> { | |
| 790 | 796 | let (repo_path, _) = resolve_repo(app, user.as_ref(), owner, repo).await?; | |
| 791 | 797 | let entries = browse::list_tree(&repo_path, rev, path).map_err(server_error)?; | |
| 798 | + | // Best-effort: a failed walk only costs the per-entry annotations. | |
| 799 | + | let entry_commits = | |
| 800 | + | browse::latest_entry_commits(&repo_path, rev, path, ENTRY_LOG_WALK).unwrap_or_default(); | |
| 792 | 801 | Ok(layout( | |
| 793 | 802 | &format!("{owner}/{repo}: {path}"), | |
| 794 | 803 | user.as_ref(), | |
| 795 | 804 | html! { | |
| 796 | 805 | h1 { a href=(format!("/{owner}/{repo}")) { (owner) "/" (repo) } } | |
| 797 | 806 | (breadcrumbs(owner, repo, rev, path, false)) | |
| 798 | - | (tree_table(owner, repo, rev, path, &entries)) | |
| 807 | + | (tree_table(owner, repo, rev, path, &entries, &entry_commits)) | |
| 799 | 808 | }, | |
| 800 | 809 | )) | |
| 801 | 810 | } | |
| ⋯ 37 unchanged lines | |||
| 839 | 848 | )) | |
| 840 | 849 | } | |
| 841 | 850 | ||
| 842 | - | /// Render a tree listing as a box of rows; directories link to `tree`, files to `blob`. | |
| 851 | + | /// How far back the per-entry "latest commit" walk looks. Entries last touched | |
| 852 | + | /// beyond this many commits just lose the annotation. | |
| 853 | + | const ENTRY_LOG_WALK: usize = 400; | |
| 854 | + | ||
| 855 | + | /// Render a tree listing as a box of rows; directories link to `tree`, files to | |
| 856 | + | /// `blob`. Each entry also shows the subject of (and links to) the latest | |
| 857 | + | /// commit that touched it, when `latest` has one for it. | |
| 843 | 858 | fn tree_table( | |
| 844 | 859 | owner: &str, | |
| 845 | 860 | repo: &str, | |
| 846 | 861 | rev: &str, | |
| 847 | 862 | path: &str, | |
| 848 | 863 | entries: &[browse::TreeEntry], | |
| 864 | + | latest: &BTreeMap<String, browse::CommitInfo>, | |
| 849 | 865 | ) -> Markup { | |
| 850 | 866 | let join = |name: &str| { | |
| 851 | 867 | if path.is_empty() { | |
| ⋯ 17 unchanged lines | |||
| 869 | 885 | span.icon { (if e.is_dir { "[ ]" } else { "·" }) } | |
| 870 | 886 | (e.name) @if e.is_dir { "/" } | |
| 871 | 887 | } | |
| 888 | + | @if let Some(c) = latest.get(&e.name) { | |
| 889 | + | a.fc-msg href=(format!("/{owner}/{repo}/commit/{}", c.id)) title=(c.summary) { (c.summary) } | |
| 890 | + | span.fc-time { (fmt_date(c.time)) } | |
| 891 | + | } | |
| 872 | 892 | } | |
| 873 | 893 | } | |
| 874 | 894 | } | |
| ⋯ 313 unchanged lines | |||
| 1188 | 1208 | } | |
| 1189 | 1209 | } | |
| 1190 | 1210 | ||
| 1211 | + | /// Format a Unix timestamp as a bare `YYYY-MM-DD` (for compact tree rows). | |
| 1212 | + | fn fmt_date(secs: i64) -> String { | |
| 1213 | + | match OffsetDateTime::from_unix_timestamp(secs) { | |
| 1214 | + | Ok(t) => format!("{:04}-{:02}-{:02}", t.year(), u8::from(t.month()), t.day()), | |
| 1215 | + | Err(_) => secs.to_string(), | |
| 1216 | + | } | |
| 1217 | + | } | |
| 1218 | + | ||
| 1191 | 1219 | /// Heuristic: treat content with a NUL in the first 8 KiB as binary. | |
| 1192 | 1220 | fn is_binary(bytes: &[u8]) -> bool { | |
| 1193 | 1221 | bytes.iter().take(8192).any(|&b| b == 0) | |
| ⋯ 1 unchanged line | |||