anvilsign in

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
175175 "anvil-core",
176176 "gitserver-core",
177177 "gix",
178+ "tempfile",
178179 "thiserror",
179180 "toasty",
180181 "tokio",
⋯ 5059 unchanged lines
modifiedcrates/anvil-git/Cargo.toml+3 −0
⋯ 14 unchanged lines
1515 tokio.workspace = true
1616 thiserror.workspace = true
1717 tracing.workspace = true
18+
19+[dev-dependencies]
20+tempfile = "3"
modifiedcrates/anvil-git/src/browse.rs+133 −0
⋯ 100 unchanged lines
101101 }
102102
103103 /// Metadata about a single commit.
104+#[derive(Clone)]
104105 pub struct CommitInfo {
105106 pub id: String,
106107 pub short: String,
⋯ 27 unchanged lines
134135 pub changes: Vec<FileChange>,
135136 }
136137
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+
137220 /// Walk commit history starting at `rev`, newest first, up to `limit` commits.
138221 pub fn commit_log(repo_path: &Path, rev: &str, limit: usize) -> Result<Vec<CommitInfo>> {
139222 let repo = gix::open(repo_path).map_err(read)?;
⋯ 212 unchanged lines
352435 _ => Ok(None),
353436 }
354437 }
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
22 //! blob viewing. Pages are plain SSR and work without JavaScript; htmx-based
33 //! progressive enhancement is a follow-up.
44
5-use std::collections::HashMap;
5+use std::collections::{BTreeMap, HashMap};
66 use std::path::PathBuf;
77 use std::sync::OnceLock;
88
⋯ 34 unchanged lines
4343 .box { border:1px solid var(--border); border-radius:6px; overflow:hidden; }
4444 .box .row { display:flex; justify-content:space-between; padding:8px 16px; border-top:1px solid var(--border); }
4545 .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; }
4750 .icon { width:16px; color:var(--muted); }
4851 table.code { border-collapse:collapse; width:100%; font:12px/1.45 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; }
4952 table.code td { padding:0 10px; vertical-align:top; white-space:pre; }
⋯ 691 unchanged lines
741744 .map_err(server_error)?
742745 .into_iter()
743746 .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();
744750
745751 Ok(layout(
746752 &format!("{owner}/{repo}"),
⋯ 11 unchanged lines
758764 span.muted style="margin-left:auto" { (c.author) " · " (fmt_time(c.time)) }
759765 }
760766 }
761- (tree_table(&owner, &repo, &rev, "", &entries))
767+ (tree_table(&owner, &repo, &rev, "", &entries, &entry_commits))
762768 },
763769 ))
764770 }
⋯ 24 unchanged lines
789795 ) -> Result<Markup, Response> {
790796 let (repo_path, _) = resolve_repo(app, user.as_ref(), owner, repo).await?;
791797 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();
792801 Ok(layout(
793802 &format!("{owner}/{repo}: {path}"),
794803 user.as_ref(),
795804 html! {
796805 h1 { a href=(format!("/{owner}/{repo}")) { (owner) "/" (repo) } }
797806 (breadcrumbs(owner, repo, rev, path, false))
798- (tree_table(owner, repo, rev, path, &entries))
807+ (tree_table(owner, repo, rev, path, &entries, &entry_commits))
799808 },
800809 ))
801810 }
⋯ 37 unchanged lines
839848 ))
840849 }
841850
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.
843858 fn tree_table(
844859 owner: &str,
845860 repo: &str,
846861 rev: &str,
847862 path: &str,
848863 entries: &[browse::TreeEntry],
864+ latest: &BTreeMap<String, browse::CommitInfo>,
849865 ) -> Markup {
850866 let join = |name: &str| {
851867 if path.is_empty() {
⋯ 17 unchanged lines
869885 span.icon { (if e.is_dir { "[ ]" } else { "·" }) }
870886 (e.name) @if e.is_dir { "/" }
871887 }
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+ }
872892 }
873893 }
874894 }
⋯ 313 unchanged lines
11881208 }
11891209 }
11901210
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+
11911219 /// Heuristic: treat content with a NUL in the first 8 KiB as binary.
11921220 fn is_binary(bytes: &[u8]) -> bool {
11931221 bytes.iter().take(8192).any(|&b| b == 0)
⋯ 1 unchanged line