File view: tree sidebar, state permalinks (y, heads joined with +), line range bar with copy lines and copy permalink, blame age shading and blame before this change 6ae41081ccd3
- Author
- Andy McCall @andymccall <[email protected]>
- When
- · 2026-09-28 11:10 UTC
- Signature
- ✔ signed by Andy McCall
- Hash
6ae41081ccd3698f9e9d3d2ce64ccf6bb1d522061eccad64895d24ba111ae8db- Depends on
- 07b1a5bbf481 64caed18236a 93be9ce4653e 9ff27da517fe d23464c0ac60
- Depended on by
- 166f6e9bf4f1 8a2dbcb56246 df15b74774ca 92574625ee9a 65be9f9c3074 83d5052bf76c 75d611ab99d1 756fe44a0f4d 43e8ca33bc5d 736f6813543d af9c6ca11c49 42fa95540468
- In bookmarks
- api-unknown-fields cancel-landed-runs clear-waiting close-proposal-140 diffusion-pin-bookmark draft-ready image-check job-waits main oh-comment-body-file proposal-list-fast run-one-job wiki-says-so
- In tags
- v0.1.0 v0.2.0 v0.3.0 v0.3.1 v0.4.0 v0.4.1 v0.4.2 v0.5.0 v0.5.1 v0.5.2 v0.6.0 v0.6.1 v0.7.0 v0.8.0 v0.9.0 v0.9.1 v0.9.11 v0.9.15 v0.9.16 v0.9.19 v0.9.2 v0.9.20 v0.9.21 v0.9.22 v0.9.23 v0.9.24 v0.9.3 v0.9.4 v0.9.5 v0.9.6 v0.9.7 v0.9.8 v0.9.9
- Edits
- 40 atoms, 0 tree operations, 10 files
Browse the tree at this change
modified assets/hub.js +25 −1
| … | 168 unchanged lines |
||
| 169 | 169 | function mark() { |
|
| 170 | 170 | var m = location.hash.match(/^#L(\d+)(?:-L(\d+))?$/); |
|
| 171 | 171 | document.querySelectorAll("tr.hl").forEach(function (r) { r.classList.remove("hl"); }); |
|
| 172 | - | if (!m) return; |
|
| 172 | + | if (!m) { showBar(0, 0); return; } |
|
| 173 | 173 | var a = +m[1], b = +(m[2] || m[1]); |
|
| 174 | 174 | for (var i = Math.min(a, b); i <= Math.max(a, b); i++) { |
|
| 175 | 175 | var row = document.getElementById("L" + i); |
|
| … | 1 unchanged lines |
||
| 177 | 177 | } |
|
| 178 | 178 | var first = document.getElementById("L" + Math.min(a, b)); |
|
| 179 | 179 | if (first && !mark.done) { first.scrollIntoView({ block: "center" }); mark.done = true; } |
|
| 180 | + | showBar(Math.min(a, b), Math.max(a, b)); |
|
| 181 | + | } |
|
| 182 | + | /* The bar for a selected range: copy its text, or a permalink to it. */ |
|
| 183 | + | var bar = document.querySelector(".linebar"); |
|
| 184 | + | function showBar(a, b) { |
|
| 185 | + | if (!bar) return; |
|
| 186 | + | bar.hidden = !a; |
|
| 187 | + | if (!a) return; |
|
| 188 | + | bar.querySelector("[data-linebar-label]").textContent = a === b ? "Line " + a : "Lines " + a + "–" + b; |
|
| 189 | + | bar.setAttribute("data-range", a + "-" + b); |
|
| 190 | + | } |
|
| 191 | + | function flash(btn) { var t = btn.textContent; btn.textContent = "Copied"; setTimeout(function () { btn.textContent = t; }, 1200); } |
|
| 192 | + | if (bar) { |
|
| 193 | + | bar.querySelector("[data-copy-lines]").addEventListener("click", function (ev) { |
|
| 194 | + | var r = bar.getAttribute("data-range").split("-"), out = []; |
|
| 195 | + | for (var i = +r[0]; i <= +r[1]; i++) { var row = document.getElementById("L" + i); if (row) out.push(row.querySelector("td.t").textContent); } |
|
| 196 | + | navigator.clipboard.writeText(out.join("\n") + "\n").then(function () { flash(ev.target); }); |
|
| 197 | + | }); |
|
| 198 | + | var pl = bar.querySelector("[data-copy-permalink]"); |
|
| 199 | + | if (pl) pl.addEventListener("click", function () { |
|
| 200 | + | navigator.clipboard.writeText(new URL(document.body.getAttribute("data-permalink"), location.href).href + location.hash).then(function () { flash(pl); }); |
|
| 201 | + | }); |
|
| 180 | 202 | } |
|
| 203 | + | function permalink() { var p = document.body.getAttribute("data-permalink"); return p ? p + location.hash : null; } |
|
| 181 | 204 | window.addEventListener("hashchange", mark); |
|
| 182 | 205 | mark(); |
|
| 183 | 206 | document.querySelectorAll("table.code td.n").forEach(function (td) { |
|
| … | 9 unchanged lines |
||
| 193 | 216 | document.addEventListener("keydown", function (e) { |
|
| 194 | 217 | if (e.target && /^(INPUT|TEXTAREA|SELECT)$/.test(e.target.tagName)) return; |
|
| 195 | 218 | if (e.key === "/") { var s = document.querySelector("input[name=q]"); if (s) { e.preventDefault(); s.focus(); } return; } |
|
| 219 | + | if (e.key === "y" && !e.ctrlKey && !e.metaKey && !e.altKey && permalink()) { history.replaceState(null, "", permalink()); pending = null; return; } |
|
| 196 | 220 | if (e.key === "?") { var k = document.getElementById("keys"); if (k) k.hidden = !k.hidden; return; } |
|
| 197 | 221 | var repo = document.body.getAttribute("data-repo"); |
|
| 198 | 222 | if (pending === "g" && repo) { |
|
| … | 7 unchanged lines |
modified crates/hub/src/repos/view.rs +198 −2
| … | 33 unchanged lines |
||
| 34 | 34 | pub heads: Vec<ChangeHash>, |
|
| 35 | 35 | } |
|
| 36 | 36 | ||
| 37 | - | /// Resolve a bookmark name, a tag name, or a change hash prefix. |
|
| 37 | + | /// Most heads a state address or a "before this change" link carries. |
|
| 38 | + | pub const MAX_STATE_HEADS: usize = 32; |
|
| 39 | + | ||
| 40 | + | impl RefTarget { |
|
| 41 | + | /// The label for a ref bar: the name, or `# abc123…` for a change, |
|
| 42 | + | /// with `+N` for the further heads of a state. |
|
| 43 | + | pub fn short_label(&self) -> String { |
|
| 44 | + | match self.kind { |
|
| 45 | + | RefKind::Change => { |
|
| 46 | + | let mut parts = self.label.split('+'); |
|
| 47 | + | let first: String = parts.next().unwrap_or("").chars().take(12).collect(); |
|
| 48 | + | match parts.count() { |
|
| 49 | + | 0 => first, |
|
| 50 | + | n => format!("{first} +{n}"), |
|
| 51 | + | } |
|
| 52 | + | } |
|
| 53 | + | _ => self.label.clone(), |
|
| 54 | + | } |
|
| 55 | + | } |
|
| 56 | + | ||
| 57 | + | /// The `{ref}` that names this target's state for good: its heads |
|
| 58 | + | /// joined with `+` (hub spec §6.6). None for an empty state. |
|
| 59 | + | pub fn permalink(&self) -> Option<String> { |
|
| 60 | + | state_spec(&self.heads) |
|
| 61 | + | } |
|
| 62 | + | } |
|
| 63 | + | ||
| 64 | + | /// Heads as a state address, sorted so one state has one address. |
|
| 65 | + | pub fn state_spec(heads: &[ChangeHash]) -> Option<String> { |
|
| 66 | + | if heads.is_empty() || heads.len() > MAX_STATE_HEADS { |
|
| 67 | + | return None; |
|
| 68 | + | } |
|
| 69 | + | let mut hex: Vec<String> = heads.iter().map(|h| h.to_hex()).collect(); |
|
| 70 | + | hex.sort(); |
|
| 71 | + | hex.dedup(); |
|
| 72 | + | Some(hex.join("+")) |
|
| 73 | + | } |
|
| 74 | + | ||
| 75 | + | /// Resolve a bookmark name, a tag name, a change hash prefix, or a state |
|
| 76 | + | /// (full hashes joined with `+`). |
|
| 38 | 77 | pub fn resolve(repo: &Repo, cache: &StateCache, spec: &str) -> Result<RefTarget> { |
|
| 78 | + | if spec.contains('+') { |
|
| 79 | + | let parts: Vec<&str> = spec.split('+').collect(); |
|
| 80 | + | if parts.len() > MAX_STATE_HEADS { |
|
| 81 | + | return Err(fail(format!("a state address has at most {MAX_STATE_HEADS} heads"))); |
|
| 82 | + | } |
|
| 83 | + | let mut heads = Vec::with_capacity(parts.len()); |
|
| 84 | + | for p in parts { |
|
| 85 | + | match ChangeHash::from_hex(p) { |
|
| 86 | + | Some(h) if p.len() == 64 && repo.store().has(&h) => heads.push(h), |
|
| 87 | + | _ => return Err(fail(format!("{p:?} is not a change of this repository"))), |
|
| 88 | + | } |
|
| 89 | + | } |
|
| 90 | + | heads.sort(); |
|
| 91 | + | heads.dedup(); |
|
| 92 | + | let label = state_spec(&heads).unwrap_or_default(); |
|
| 93 | + | return Ok(RefTarget { label, kind: RefKind::Change, heads }); |
|
| 94 | + | } |
|
| 39 | 95 | let view = repo.view()?; |
|
| 40 | 96 | if let Some(heads) = view.bookmarks.get(spec).map(|st| repo.store().get_state(st).map(|s| s.heads)).transpose()? { |
|
| 41 | 97 | return Ok(RefTarget { label: spec.into(), kind: RefKind::Bookmark, heads }); |
|
| … | 397 unchanged lines |
||
| 439 | 495 | Text(String), |
|
| 440 | 496 | Binary, |
|
| 441 | 497 | TooLarge, |
|
| 498 | + | } |
|
| 499 | + | ||
| 500 | + | /// One row of the file page's tree sidebar (hub spec §6.6). |
|
| 501 | + | #[derive(Clone, Debug, PartialEq, Eq)] |
|
| 502 | + | pub struct SideEntry { |
|
| 503 | + | pub name: String, |
|
| 504 | + | pub path: String, |
|
| 505 | + | pub depth: usize, |
|
| 506 | + | pub dir: bool, |
|
| 507 | + | /// A directory on the way to the file (expanded), or the file itself. |
|
| 508 | + | pub on_path: bool, |
|
| 509 | + | /// A "more" row standing for the rest of a long directory. |
|
| 510 | + | pub more: bool, |
|
| 511 | + | } |
|
| 512 | + | ||
| 513 | + | /// Entries listed per directory in the sidebar before a "more" row. |
|
| 514 | + | const SIDEBAR_PER_DIR: usize = 200; |
|
| 515 | + | ||
| 516 | + | /// The tree down to `path`: each directory on the way expanded, in tree |
|
| 517 | + | /// order (directories first, then case-insensitive names). |
|
| 518 | + | pub fn sidebar(snapshot: &Snapshot, path: &str) -> Vec<SideEntry> { |
|
| 519 | + | sidebar_in(&snapshot.files, |f| f.kind == NodeKind::Dir, path) |
|
| 520 | + | } |
|
| 521 | + | ||
| 522 | + | /// `sidebar` over any path map, given which values are directories. |
|
| 523 | + | pub fn sidebar_in<V>(files: &BTreeMap<String, V>, is_dir: impl Fn(&V) -> bool + Copy, path: &str) -> Vec<SideEntry> { |
|
| 524 | + | let mut out = Vec::new(); |
|
| 525 | + | sidebar_dir(files, is_dir, "", path, 0, &mut out); |
|
| 526 | + | out |
|
| 527 | + | } |
|
| 528 | + | ||
| 529 | + | fn sidebar_dir<V>( |
|
| 530 | + | files: &BTreeMap<String, V>, |
|
| 531 | + | is_dir: impl Fn(&V) -> bool + Copy, |
|
| 532 | + | dir: &str, |
|
| 533 | + | target: &str, |
|
| 534 | + | depth: usize, |
|
| 535 | + | out: &mut Vec<SideEntry>, |
|
| 536 | + | ) { |
|
| 537 | + | let prefix = if dir.is_empty() { String::new() } else { format!("{dir}/") }; |
|
| 538 | + | let mut children: Vec<(&str, bool)> = files |
|
| 539 | + | .range(prefix.clone()..) |
|
| 540 | + | .take_while(|(p, _)| p.starts_with(&prefix)) |
|
| 541 | + | .filter(|(p, _)| !p[prefix.len()..].contains('/')) |
|
| 542 | + | .map(|(p, f)| (p.as_str(), is_dir(f))) |
|
| 543 | + | .collect(); |
|
| 544 | + | children.sort_by(|a, b| (!a.1).cmp(&!b.1).then_with(|| a.0.to_lowercase().cmp(&b.0.to_lowercase()))); |
|
| 545 | + | let total = children.len(); |
|
| 546 | + | for (i, (p, dir_entry)) in children.into_iter().enumerate() { |
|
| 547 | + | let on_path = target == p || target.starts_with(&format!("{p}/")); |
|
| 548 | + | // Past the limit, keep only the entry on the way to the file. |
|
| 549 | + | if i >= SIDEBAR_PER_DIR && !on_path { |
|
| 550 | + | continue; |
|
| 551 | + | } |
|
| 552 | + | out.push(SideEntry { name: p[prefix.len()..].to_string(), path: p.to_string(), depth, dir: dir_entry, on_path, more: false }); |
|
| 553 | + | if dir_entry && on_path && p != target { |
|
| 554 | + | sidebar_dir(files, is_dir, p, target, depth + 1, out); |
|
| 555 | + | } |
|
| 556 | + | } |
|
| 557 | + | if total > SIDEBAR_PER_DIR { |
|
| 558 | + | out.push(SideEntry { |
|
| 559 | + | name: format!("{} more", total - SIDEBAR_PER_DIR), |
|
| 560 | + | path: dir.to_string(), |
|
| 561 | + | depth, |
|
| 562 | + | dir: true, |
|
| 563 | + | on_path: false, |
|
| 564 | + | more: true, |
|
| 565 | + | }); |
|
| 566 | + | } |
|
| 442 | 567 | } |
|
| 443 | 568 | ||
| 444 | 569 | #[derive(Clone, Debug)] |
|
| … | 10 unchanged lines |
||
| 455 | 580 | /// Raw bytes, for the `?plain=1` response and images. |
|
| 456 | 581 | pub bytes: Vec<u8>, |
|
| 457 | 582 | pub last_change: Option<ChangeView>, |
|
| 583 | + | pub sidebar: Vec<SideEntry>, |
|
| 458 | 584 | } |
|
| 459 | 585 | ||
| 460 | 586 | pub fn file(repo: &Repo, cache: &StateCache, target: RefTarget, path: &str, render_limit: u64) -> Result<FileView> { |
|
| … | 42 unchanged lines |
||
| 503 | 629 | conflicted: f.is_conflicted(), |
|
| 504 | 630 | bytes, |
|
| 505 | 631 | last_change, |
|
| 632 | + | sidebar: sidebar(snapshot, path), |
|
| 506 | 633 | }) |
|
| 507 | 634 | } |
|
| 508 | 635 | ||
| … | 52 unchanged lines |
||
| 561 | 688 | pub struct Blame { |
|
| 562 | 689 | pub file: FileView, |
|
| 563 | 690 | pub lines: Vec<BlameLine>, |
|
| 691 | + | /// For each blamed change, the state address of its dependencies: |
|
| 692 | + | /// the state its author built on (None without dependencies). |
|
| 693 | + | pub before: HashMap<ChangeHash, Option<String>>, |
|
| 564 | 694 | } |
|
| 565 | 695 | ||
| 566 | 696 | pub fn blame(repo: &Repo, cache: &StateCache, target: RefTarget, path: &str, render_limit: u64) -> Result<Blame> { |
|
| 567 | 697 | let heads = target.heads.clone(); |
|
| 568 | 698 | let file = file(repo, cache, target, path, render_limit)?; |
|
| 569 | 699 | let lines = repo.blame_in(path, &heads)?; |
|
| 570 | - | Ok(Blame { file, lines }) |
|
| 700 | + | let mut before = HashMap::new(); |
|
| 701 | + | for l in &lines { |
|
| 702 | + | before.entry(l.hash).or_insert_with(|| repo.store().get_change(&l.hash).ok().and_then(|c| state_spec(&c.deps))); |
|
| 703 | + | } |
|
| 704 | + | Ok(Blame { file, lines, before }) |
|
| 571 | 705 | } |
|
| 572 | 706 | ||
| 573 | 707 | // -------------------------------------------------------------- overview |
|
| … | 344 unchanged lines |
||
| 918 | 1052 | } |
|
| 919 | 1053 | } |
|
| 920 | 1054 | Ok(Trust { policy, verification: verification(repo, cache)?, hub_key: hub_key.to_hex(), hub_delegated_by: delegated }) |
|
| 1055 | + | } |
|
| 1056 | + | ||
| 1057 | + | #[cfg(test)] |
|
| 1058 | + | mod tests { |
|
| 1059 | + | use super::*; |
|
| 1060 | + | ||
| 1061 | + | fn tree(paths: &[&str]) -> BTreeMap<String, bool> { |
|
| 1062 | + | let mut m = BTreeMap::new(); |
|
| 1063 | + | for p in paths { |
|
| 1064 | + | let mut end = 0; |
|
| 1065 | + | let parts: Vec<&str> = p.split('/').collect(); |
|
| 1066 | + | for (i, part) in parts.iter().enumerate() { |
|
| 1067 | + | end += part.len(); |
|
| 1068 | + | m.insert(p[..end].to_string(), i + 1 < parts.len()); |
|
| 1069 | + | end += 1; |
|
| 1070 | + | } |
|
| 1071 | + | } |
|
| 1072 | + | m |
|
| 1073 | + | } |
|
| 1074 | + | ||
| 1075 | + | #[test] |
|
| 1076 | + | fn sidebar_expands_the_path_only() { |
|
| 1077 | + | let files = tree(&["README.md", "src/main.rs", "src/lib/a.rs", "src/lib/b.rs", "docs/guide.md", "Zeta.txt"]); |
|
| 1078 | + | let rows: Vec<(String, usize, bool, bool)> = |
|
| 1079 | + | sidebar_in(&files, |d| *d, "src/lib/b.rs").into_iter().map(|e| (e.path, e.depth, e.dir, e.on_path)).collect(); |
|
| 1080 | + | let want = [ |
|
| 1081 | + | ("docs", 0, true, false), |
|
| 1082 | + | ("src", 0, true, true), |
|
| 1083 | + | ("src/lib", 1, true, true), |
|
| 1084 | + | ("src/lib/a.rs", 2, false, false), |
|
| 1085 | + | ("src/lib/b.rs", 2, false, true), |
|
| 1086 | + | ("src/main.rs", 1, false, false), |
|
| 1087 | + | ("README.md", 0, false, false), |
|
| 1088 | + | ("Zeta.txt", 0, false, false), |
|
| 1089 | + | ]; |
|
| 1090 | + | assert_eq!(rows, want.iter().map(|(p, d, dir, on)| (p.to_string(), *d, *dir, *on)).collect::<Vec<_>>()); |
|
| 1091 | + | } |
|
| 1092 | + | ||
| 1093 | + | #[test] |
|
| 1094 | + | fn sidebar_cuts_long_directories_but_keeps_the_file() { |
|
| 1095 | + | let names: Vec<String> = (0..250).map(|i| format!("big/f{i:03}.txt")).collect(); |
|
| 1096 | + | let refs: Vec<&str> = names.iter().map(String::as_str).collect(); |
|
| 1097 | + | let files = tree(&refs); |
|
| 1098 | + | let rows = sidebar_in(&files, |d| *d, "big/f240.txt"); |
|
| 1099 | + | let listed: Vec<&SideEntry> = rows.iter().filter(|e| e.depth == 1 && !e.more).collect(); |
|
| 1100 | + | assert_eq!(listed.len(), SIDEBAR_PER_DIR + 1, "the limit plus the file itself"); |
|
| 1101 | + | assert!(listed.last().unwrap().on_path); |
|
| 1102 | + | let more = rows.iter().find(|e| e.more).unwrap(); |
|
| 1103 | + | assert_eq!((more.name.as_str(), more.path.as_str()), ("50 more", "big")); |
|
| 1104 | + | } |
|
| 1105 | + | ||
| 1106 | + | #[test] |
|
| 1107 | + | fn state_addresses_are_sorted_and_bounded() { |
|
| 1108 | + | let a = ChangeHash::from_hex(&"a".repeat(64)).unwrap(); |
|
| 1109 | + | let b = ChangeHash::from_hex(&"b".repeat(64)).unwrap(); |
|
| 1110 | + | assert_eq!(state_spec(&[b, a, b]).unwrap(), format!("{}+{}", "a".repeat(64), "b".repeat(64))); |
|
| 1111 | + | assert_eq!(state_spec(&[]), None); |
|
| 1112 | + | assert_eq!(state_spec(&vec![a; MAX_STATE_HEADS + 1]), None); |
|
| 1113 | + | let t = RefTarget { label: state_spec(&[a, b]).unwrap(), kind: RefKind::Change, heads: vec![a, b] }; |
|
| 1114 | + | assert_eq!(t.short_label(), format!("{} +1", "a".repeat(12))); |
|
| 1115 | + | assert_eq!(RefTarget { label: "main".into(), kind: RefKind::Bookmark, heads: vec![a] }.short_label(), "main"); |
|
| 1116 | + | } |
|
| 921 | 1117 | } |
modified crates/hub/src/web/repo.rs +49 −25
| … | 190 unchanged lines |
||
| 191 | 191 | is_image: bool, |
|
| 192 | 192 | is_markdown: bool, |
|
| 193 | 193 | blame: Option<Vec<BlameRow>>, |
|
| 194 | + | /// The `{ref}` of this state for good (hub spec §6.6). |
|
| 195 | + | permalink: Option<String>, |
|
| 194 | 196 | } |
|
| 195 | 197 | ||
| 196 | 198 | /// A blame gutter entry: shown only on the first line of a run. |
|
| … | 4 unchanged lines |
||
| 201 | 203 | pub author: String, |
|
| 202 | 204 | pub when_ms: u64, |
|
| 203 | 205 | pub run: usize, |
|
| 206 | + | /// 0 (the file's oldest line) to 9 (its newest), for shading. |
|
| 207 | + | pub age: u8, |
|
| 208 | + | /// The state address of the change's dependencies. |
|
| 209 | + | pub before: Option<String>, |
|
| 210 | + | } |
|
| 211 | + | ||
| 212 | + | /// Shade steps from `when` within the file's span of times. |
|
| 213 | + | fn age_step(when: u64, oldest: u64, newest: u64) -> u8 { |
|
| 214 | + | if newest <= oldest { |
|
| 215 | + | return 9; |
|
| 216 | + | } |
|
| 217 | + | (((when.saturating_sub(oldest)) as u128 * 9 / (newest - oldest) as u128) as u8).min(9) |
|
| 218 | + | } |
|
| 219 | + | ||
| 220 | + | fn blame_rows( |
|
| 221 | + | lines: &[oxy_core::repo::BlameLine], |
|
| 222 | + | before: &std::collections::HashMap<oxy_core::hash::ChangeHash, Option<String>>, |
|
| 223 | + | ) -> Vec<BlameRow> { |
|
| 224 | + | let oldest = lines.iter().map(|l| l.when_ms).min().unwrap_or(0); |
|
| 225 | + | let newest = lines.iter().map(|l| l.when_ms).max().unwrap_or(0); |
|
| 226 | + | let mut rows: Vec<BlameRow> = Vec::with_capacity(lines.len()); |
|
| 227 | + | let mut i = 0; |
|
| 228 | + | while i < lines.len() { |
|
| 229 | + | let mut run = 1; |
|
| 230 | + | while i + run < lines.len() && lines[i + run].hash == lines[i].hash { |
|
| 231 | + | run += 1; |
|
| 232 | + | } |
|
| 233 | + | for k in 0..run { |
|
| 234 | + | let l = &lines[i + k]; |
|
| 235 | + | rows.push(BlameRow { |
|
| 236 | + | first: k == 0, |
|
| 237 | + | hash: l.hash.to_hex(), |
|
| 238 | + | short: l.hash.short(8), |
|
| 239 | + | author: l.author.clone(), |
|
| 240 | + | when_ms: l.when_ms, |
|
| 241 | + | run, |
|
| 242 | + | age: age_step(l.when_ms, oldest, newest), |
|
| 243 | + | before: before.get(&l.hash).cloned().flatten(), |
|
| 244 | + | }); |
|
| 245 | + | } |
|
| 246 | + | i += run; |
|
| 247 | + | } |
|
| 248 | + | rows |
|
| 204 | 249 | } |
|
| 205 | 250 | ||
| 206 | 251 | pub async fn file( |
|
| … | 13 unchanged lines |
||
| 220 | 265 | let target = view::resolve(repo, cache, &spec)?; |
|
| 221 | 266 | if want_blame { |
|
| 222 | 267 | let b = view::blame(repo, cache, target, &p, limit)?; |
|
| 223 | - | Ok((b.file, Some(b.lines))) |
|
| 268 | + | let rows = blame_rows(&b.lines, &b.before); |
|
| 269 | + | Ok((b.file, Some(rows))) |
|
| 224 | 270 | } else { |
|
| 225 | 271 | Ok((view::file(repo, cache, target, &p, limit)?, None)) |
|
| 226 | 272 | } |
|
| … | 19 unchanged lines |
||
| 246 | 292 | } |
|
| 247 | 293 | _ => (vec![], None, None), |
|
| 248 | 294 | }; |
|
| 249 | - | let blame = blame.map(|lines| { |
|
| 250 | - | let mut rows: Vec<BlameRow> = Vec::with_capacity(lines.len()); |
|
| 251 | - | let mut i = 0; |
|
| 252 | - | while i < lines.len() { |
|
| 253 | - | let mut run = 1; |
|
| 254 | - | while i + run < lines.len() && lines[i + run].hash == lines[i].hash { |
|
| 255 | - | run += 1; |
|
| 256 | - | } |
|
| 257 | - | for k in 0..run { |
|
| 258 | - | let l = &lines[i + k]; |
|
| 259 | - | rows.push(BlameRow { |
|
| 260 | - | first: k == 0, |
|
| 261 | - | hash: l.hash.to_hex(), |
|
| 262 | - | short: l.hash.short(8), |
|
| 263 | - | author: l.author.clone(), |
|
| 264 | - | when_ms: l.when_ms, |
|
| 265 | - | run, |
|
| 266 | - | }); |
|
| 267 | - | } |
|
| 268 | - | i += run; |
|
| 269 | - | } |
|
| 270 | - | rows |
|
| 271 | - | }); |
|
| 272 | - | Ok(Page(FilePage { shell, repo: rs, f, lines, language, markdown_html, is_image, is_markdown, blame }).into_response()) |
|
| 295 | + | let permalink = f.target.permalink(); |
|
| 296 | + | Ok(Page(FilePage { shell, repo: rs, f, lines, language, markdown_html, is_image, is_markdown, blame, permalink }).into_response()) |
|
| 273 | 297 | } |
|
| 274 | 298 | ||
| 275 | 299 | #[derive(Deserialize)] |
|
| … | 143 unchanged lines |
modified crates/hub/tests/browse.rs +18 −0
| … | 134 unchanged lines |
||
| 135 | 135 | assert_eq!(raw, "fn main() {\n println!(\"hello\");\n}\n\nfn two() {}\n"); |
|
| 136 | 136 | let b = page(&h.app, "/andy/demo/file/main/src/main.rs?blame=1").await; |
|
| 137 | 137 | assert!(b.contains(&c1[..8]) && b.contains(&c2[..8]), "both changes blamed: {b}"); |
|
| 138 | + | // Sidebar, permalink, blame shading and "blame before this change" (hub spec §6.6). |
|
| 139 | + | assert!(f.contains("class=\"file-side\""), "sidebar"); |
|
| 140 | + | assert!(f.contains("href=\"/andy/demo/tree/main/docs\""), "sibling directory listed"); |
|
| 141 | + | let here = f.split("aria-current=\"page\">").nth(1).unwrap_or(""); |
|
| 142 | + | assert!(here.trim_start().starts_with("<a href=\"/andy/demo/file/main/src/main.rs\""), "the file marked"); |
|
| 143 | + | assert!(f.contains(&format!("data-permalink=\"/andy/demo/file/{c2}/src/main.rs\"")), "permalink at the head"); |
|
| 144 | + | assert!(b.contains(&format!("data-permalink=\"/andy/demo/file/{c2}/src/main.rs?blame=1\""))); |
|
| 145 | + | assert!(b.contains("blame age-0") && b.contains("blame age-9"), "oldest and newest shaded"); |
|
| 146 | + | assert!(b.contains(&format!("href=\"/andy/demo/file/{c1}/src/main.rs?blame=1#L")), "the state before c2 is c1"); |
|
| 147 | + | assert_eq!(b.matches("class=\"before\"").count(), b.matches("blame age-9").count(), "c1 has no dependencies, so no link"); |
|
| 148 | + | let before = page(&h.app, &format!("/andy/demo/file/{c1}/src/main.rs?blame=1")).await; |
|
| 149 | + | assert!(before.contains(&c1[..8]) && !before.contains(&c2[..8]), "blamed in c1's state"); |
|
| 150 | + | // States: heads joined with +, in any order; not a change otherwise. |
|
| 151 | + | let (lo, hi) = if c1 < c2 { (&c1, &c2) } else { (&c2, &c1) }; |
|
| 152 | + | assert!(page(&h.app, &format!("/andy/demo/tree/{hi}+{lo}/src")) |
|
| 153 | + | .await |
|
| 154 | + | .contains(&format!("data-permalink=\"/andy/demo/tree/{lo}+{hi}/src\""))); |
|
| 155 | + | assert_ne!(get(&h.app, &format!("/andy/demo/tree/{c1}+{}", &c2[..12])).await.0, StatusCode::OK, "state heads are full hashes"); |
|
| 138 | 156 | let md = page(&h.app, "/andy/demo/file/main/README.md").await; |
|
| 139 | 157 | assert!(md.contains("<h1>Demo</h1>"), "markdown rendered"); |
|
| 140 | 158 | // Last-change JSON for tree rows. |
|
| … | 83 unchanged lines |
modified docs/spec.md +8 −2
| … | 229 unchanged lines |
||
| 230 | 230 | | `/{owner}` | A person: identicon, display name, bio, location, website, linked signing keys, organisations, repositories the viewer can see, and their recent activity (never the email). An organisation: identicon, description, members with roles, teams with sizes, repositories, recent activity in them. `/avatar/{name}` serves the identicon (SVG, a mirrored 5×5 pattern and hue from BLAKE3 of the lower-cased name). | |
|
| 231 | 231 | | `/{owner}/{name}` | Repository overview (§6.4); while empty, a quickstart with the exact commands for this hub (remote, certificate, token, push; start from nothing; import from Git) | |
|
| 232 | 232 | | `/{owner}/{name}/fork`, `/{owner}/{name}/forks` | Fork under an owner the viewer may create repositories for (same visibility, own repository id, objects hard-linked, bookmarks copied, policy not copied); the list of forks the viewer can see. The overview shows "forked from", the fork count, and on a fork a link that opens a proposal upstream with `owner/name:bookmark` as its source | |
|
| 233 | - | | `/{owner}/{name}/tree/{ref}/{path}` | Directory listing at a bookmark, tag or change (§6.5) | |
|
| 233 | + | | `/{owner}/{name}/tree/{ref}/{path}` | Directory listing at a bookmark, tag, change or state (§6.5). A `{ref}` is a bookmark name, a tag name, a change hash or unique prefix, or a state: full change hashes joined with `+` (`a1…+b2…`), the state those heads make, which is how a state with several heads is addressed permanently | |
|
| 234 | 234 | | `/{owner}/{name}/file/{ref}/{path}` | File view, `?plain=1` raw, `?blame=1` blame (§6.6) | |
|
| 235 | 235 | | `/{owner}/{name}/changes/{ref}` | History (§6.7) | |
|
| 236 | 236 | | `/{owner}/{name}/change/{hash}` | Change page (§6.8) | |
|
| … | 27 unchanged lines |
||
| 264 | 264 | Text files render with syntect line highlighting, line numbers as anchors (`#L12`, `#L12-L20` selects a range), a "raw", "history", "blame", "edit" toolbar, and the file's last change. Markdown renders by default with a toggle to source. Images and PDFs render inline; other binaries offer download. Files above `render_limit` (default 2 MiB) show the first part and a download link. |
|
| 265 | 265 | ||
| 266 | 266 | Blame uses `Repo::blame`: every line is exact, because each line is an atom with one authoring change. The gutter shows the change's short hash, author and age, grouped into runs, with the change's signature glyph. Blame is exact through rebase and squash, so there is no "ignore revs" file. |
|
| 267 | + | ||
| 268 | + | A sidebar beside the file lists the tree down to it: the root and each directory on the file's path expanded (at most 200 entries each, then a link to the directory), the file marked, other directories linking to their tree pages. It is hidden on narrow screens. |
|
| 269 | + | ||
| 270 | + | The permalink of a file view is the same path at its state: the target's heads joined with `+` in place of the bookmark or tag. `y` replaces the address with the permalink, keeping the line anchor, as GitHub does. When a line or range is selected, a bar offers **Copy lines** (the text) and **Copy permalink** (the permalink with `#L12-L20`). |
|
| 271 | + | ||
| 272 | + | The blame gutter shades each run by the age of its change, from the file's oldest (lightest) to newest (strongest) line, in ten steps, with the age also written out so colour is not the only carrier. Each run links **blame before this change**: the same file blamed at the state of the change's dependencies (the state its author built on), so a line can be traced back through every edit. A change without dependencies has no such link; one with more than 32 has none either, to keep addresses bounded. |
|
| 267 | 273 | ||
| 268 | 274 | ### 6.7 History |
|
| 269 | 275 | ||
| … | 29 unchanged lines |
||
| 299 | 305 | ||
| 300 | 306 | ### 6.13 Keyboard and accessibility |
|
| 301 | 307 | ||
| 302 | - | `/` focuses search, `g c` / `g p` / `g i` jump to Code, Proposals, Issues, `?` shows the sheet. Every interactive element is reachable by keyboard; colour is never the only carrier of meaning (glyphs accompany every status colour); contrast meets WCAG AA in both themes. |
|
| 308 | + | `/` focuses search, `g c` / `g p` / `g i` jump to Code, Proposals, Issues, `y` on a tree or file page switches to its permalink, `?` shows the sheet. Every interactive element is reachable by keyboard; colour is never the only carrier of meaning (glyphs accompany every status colour); contrast meets WCAG AA in both themes. |
|
| 303 | 309 | ||
| 304 | 310 | ## 7. Proposals and review |
|
| 305 | 311 | ||
| … | 244 unchanged lines |
modified templates/base.html +1 −1
| … | 50 unchanged lines |
||
| 51 | 51 | </footer> |
|
| 52 | 52 | <div id="keys" class="sheet" hidden> |
|
| 53 | 53 | <h2>Keyboard</h2> |
|
| 54 | - | <table><tr><td><kbd>/</kbd></td><td>Search</td></tr><tr><td><kbd>g</kbd> <kbd>c</kbd></td><td>Code</td></tr><tr><td><kbd>g</kbd> <kbd>h</kbd></td><td>History</td></tr><tr><td><kbd>g</kbd> <kbd>b</kbd></td><td>Bookmarks</td></tr><tr><td><kbd>g</kbd> <kbd>t</kbd></td><td>Trust</td></tr><tr><td><kbd>?</kbd></td><td>This sheet</td></tr></table> |
|
| 54 | + | <table><tr><td><kbd>/</kbd></td><td>Search</td></tr><tr><td><kbd>g</kbd> <kbd>c</kbd></td><td>Code</td></tr><tr><td><kbd>g</kbd> <kbd>h</kbd></td><td>History</td></tr><tr><td><kbd>g</kbd> <kbd>b</kbd></td><td>Bookmarks</td></tr><tr><td><kbd>g</kbd> <kbd>t</kbd></td><td>Trust</td></tr><tr><td><kbd>y</kbd></td><td>Permalink to this state (files and trees)</td></tr><tr><td><kbd>?</kbd></td><td>This sheet</td></tr></table> |
|
| 55 | 55 | </div> |
|
| 56 | 56 | <script src="/static/hub.js" defer></script> |
|
| 57 | 57 | </body> |
|
| … | 1 unchanged lines |
modified templates/file.html +20 −5
| 1 | 1 | {% extends "base.html" %} |
|
| 2 | 2 | {% import "repo_tabs.html" as m %} |
|
| 3 | - | {% block title %}{{ f.path }} at {{ f.target.label|short }} · {{ repo.owner }}/{{ repo.name }}{% endblock %} |
|
| 4 | - | {% block bodyattrs %} data-repo="{{ repo.url }}" data-ref="{{ f.target.label }}"{% endblock %} |
|
| 3 | + | {% block title %}{{ f.path }} at {{ f.target.short_label() }} · {{ repo.owner }}/{{ repo.name }}{% endblock %} |
|
| 4 | + | {% block bodyattrs %} data-repo="{{ repo.url }}" data-ref="{{ f.target.label }}"{% if let Some(p) = permalink %} data-permalink="{{ repo.url }}/file/{{ p }}/{{ f.path }}{% if blame.is_some() %}?blame=1{% endif %}"{% endif %}{% endblock %} |
|
| 5 | 5 | {% block tabs %}{% call m::tabs(repo, "code") %}{% endblock %} |
|
| 6 | 6 | {% block mainclass %} class="wide"{% endblock %} |
|
| 7 | 7 | {% block content %} |
|
| 8 | 8 | <div class="bar"> |
|
| 9 | - | <span class="ref px">{% if f.target.kind == crate::repos::view::RefKind::Change %}# {{ f.target.label|short }}{% else %}◆ {{ f.target.label }}{% endif %}</span> |
|
| 9 | + | <span class="ref px">{% if f.target.kind == crate::repos::view::RefKind::Change %}# {{ f.target.short_label() }}{% else %}◆ {{ f.target.label }}{% endif %}</span> |
|
| 10 | 10 | <span class="crumbs"><a href="{{ repo.url }}/tree/{{ f.target.label }}">{{ repo.name }}</a>{% for (n, p) in f.crumbs %} / {% if loop.last %}<strong>{{ n }}</strong>{% else %}<a href="{{ repo.url }}/tree/{{ f.target.label }}/{{ p }}">{{ n }}</a>{% endif %}{% endfor %}</span> |
|
| 11 | 11 | </div> |
|
| 12 | - | <section class="filebox"> |
|
| 12 | + | <div class="file-layout"> |
|
| 13 | + | <nav class="file-side" aria-label="Files"> |
|
| 14 | + | <a class="side-root" href="{{ repo.url }}/tree/{{ f.target.label }}">{{ repo.name }}</a> |
|
| 15 | + | <ul> |
|
| 16 | + | {% for e in f.sidebar %} |
|
| 17 | + | <li style="--d: {{ e.depth }}"{% if e.on_path && !e.dir %} class="here" aria-current="page"{% endif %}> |
|
| 18 | + | {% if e.more %}<a class="mute" href="{{ repo.url }}/tree/{{ f.target.label }}/{{ e.path }}">… {{ e.name }}</a> |
|
| 19 | + | {% else if e.dir %}<a href="{{ repo.url }}/tree/{{ f.target.label }}/{{ e.path }}"><span class="glyph" aria-hidden="true">{% if e.on_path %}▾{% else %}▸{% endif %}</span> {{ e.name }}/</a> |
|
| 20 | + | {% else %}<a href="{{ repo.url }}/file/{{ f.target.label }}/{{ e.path }}">{{ e.name }}</a>{% endif %} |
|
| 21 | + | </li> |
|
| 22 | + | {% endfor %} |
|
| 23 | + | </ul> |
|
| 24 | + | </nav> |
|
| 25 | + | <section class="filebox file-main"> |
|
| 13 | 26 | <header> |
|
| 14 | 27 | <span class="small mute">{{ f.size|bytes }}{% if let Some(l) = language %} · {{ l }}{% endif %}{% if f.exec %} · executable{% endif %}{% if f.kind == crate::repos::view::EntryKind::Symlink %} · symlink{% endif %}{% if f.kind == crate::repos::view::EntryKind::Subrepo %} · subrepository pin{% endif %}{% if let Some(c) = f.last_change %} · last changed <a href="{{ repo.url }}/change/{{ c.hash }}">{{ c.title }}</a> {{ c.when_ms|ago }}{% endif %}</span> |
|
| 15 | 28 | <span class="modes"> |
|
| … | 5 unchanged lines |
||
| 21 | 34 | {% if repo.can(crate::auth::Access::Write) %}<a href="{{ repo.url }}/edit/{{ f.target.label }}/{{ f.path }}">edit</a>{% endif %} |
|
| 22 | 35 | </span> |
|
| 23 | 36 | </header> |
|
| 37 | + | <div class="linebar" hidden><span class="small mute" data-linebar-label></span><button type="button" class="btn small" data-copy-lines>Copy lines</button>{% if permalink.is_some() %}<button type="button" class="btn small" data-copy-permalink>Copy permalink</button>{% endif %}</div> |
|
| 24 | 38 | {% if f.conflicted %}<div class="strip bad">This file is in conflict in this state; the markers below are the conflict. <a href="{{ repo.url }}/conflicts">All conflicts</a></div>{% endif %} |
|
| 25 | 39 | {% if is_image %} |
|
| 26 | 40 | <p class="center"><img src="{{ repo.url }}/file/{{ f.target.label }}/{{ f.path }}?plain=1" alt="{{ f.name }}"></p> |
|
| … | 10 unchanged lines |
||
| 37 | 51 | {% let n = loop.index %} |
|
| 38 | 52 | <tr id="L{{ n }}"> |
|
| 39 | 53 | {% if let Some(b) = blame %}{% let r = b[loop.index0] %} |
|
| 40 | - | {% if r.first %}<td class="blame" rowspan="{{ r.run }}"><a class="px" href="{{ repo.url }}/change/{{ r.hash }}" title="{{ r.hash }}">{{ r.short }}</a> <span class="who">{{ r.author }}</span> <time class="mute" title="{{ r.when_ms|when }}">{{ r.when_ms|ago }}</time></td>{% endif %} |
|
| 54 | + | {% if r.first %}<td class="blame age-{{ r.age }}" rowspan="{{ r.run }}"><a class="px" href="{{ repo.url }}/change/{{ r.hash }}" title="{{ r.hash }}">{{ r.short }}</a> <span class="who">{{ r.author }}</span> <time class="mute" title="{{ r.when_ms|when }}">{{ r.when_ms|ago }}</time>{% if let Some(b) = r.before %} <a class="before" href="{{ repo.url }}/file/{{ b }}/{{ f.path }}?blame=1#L{{ n }}" title="Blame the state before this change">↶</a>{% endif %}</td>{% endif %} |
|
| 41 | 55 | {% endif %} |
|
| 42 | 56 | <td class="n">{{ n }}</td> |
|
| 43 | 57 | <td class="t"><pre>{{ line|safe }}</pre></td> |
|
| … | 4 unchanged lines |
||
| 48 | 62 | {% endmatch %} |
|
| 49 | 63 | {% endif %} |
|
| 50 | 64 | </section> |
|
| 65 | + | </div> |
|
| 51 | 66 | {% endblock %} |
modified templates/history.html +2 −2
| 1 | 1 | {% extends "base.html" %} |
|
| 2 | 2 | {% import "repo_tabs.html" as m %} |
|
| 3 | - | {% block title %}Changes on {{ h.target.label|short }} · {{ repo.owner }}/{{ repo.name }}{% endblock %} |
|
| 3 | + | {% block title %}Changes on {{ h.target.short_label() }} · {{ repo.owner }}/{{ repo.name }}{% endblock %} |
|
| 4 | 4 | {% block bodyattrs %} data-repo="{{ repo.url }}" data-ref="{{ h.target.label }}"{% endblock %} |
|
| 5 | 5 | {% block tabs %}{% call m::tabs(repo, "changes") %}{% endblock %} |
|
| 6 | 6 | {% block content %} |
|
| 7 | - | <div class="head"><h1>Changes <small>{% if h.target.kind == crate::repos::view::RefKind::Change %}up to # {{ h.target.label|short }}{% else %}on {{ h.target.label }}{% endif %}{% if let Some(p) = h.path %} touching {{ p }}{% endif %} · {{ h.total }}</small></h1></div> |
|
| 7 | + | <div class="head"><h1>Changes <small>{% if h.target.kind == crate::repos::view::RefKind::Change %}up to # {{ h.target.short_label() }}{% else %}on {{ h.target.label }}{% endif %}{% if let Some(p) = h.path %} touching {{ p }}{% endif %} · {{ h.total }}</small></h1></div> |
|
| 8 | 8 | {% if h.target.heads.len() > 1 %}<div class="strip warn">{{ h.target.label }} has {{ h.target.heads.len() }} heads. The graph shows their union; heads are marked.</div>{% endif %} |
|
| 9 | 9 | {% if h.entries.is_empty() %}<p class="empty">No changes.</p>{% else %} |
|
| 10 | 10 | <div class="hist" style="--lanes: {{ lanes }}"> |
|
| … | 20 unchanged lines |
modified templates/style.css +25 −0
| … | 383 unchanged lines |
||
| 384 | 384 | .section-nav { position: static; flex-direction: row; flex-wrap: wrap; } |
|
| 385 | 385 | .section-nav a { border-left: 0; border-bottom: 2px solid var(--line); } |
|
| 386 | 386 | } |
|
| 387 | + | ||
| 388 | + | /* File view: tree sidebar, line bar, blame age (hub spec §6.6). */ |
|
| 389 | + | .file-layout { display: grid; grid-template-columns: minmax(180px, 240px) minmax(0, 1fr); gap: 16px; align-items: start; } |
|
| 390 | + | .file-side { position: sticky; top: 12px; max-height: calc(100vh - 24px); overflow: auto; font-size: 13px; background: var(--panel); border: 1px solid var(--line); padding: 8px 0; } |
|
| 391 | + | .file-side .side-root { display: block; padding: 2px 12px 6px; font-weight: 600; } |
|
| 392 | + | .file-side ul { list-style: none; margin: 0; padding: 0; } |
|
| 393 | + | .file-side li a { display: block; padding: 2px 12px 2px calc(12px + var(--d, 0) * 14px); color: var(--text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } |
|
| 394 | + | .file-side li a:hover { background: var(--soft); text-decoration: none; } |
|
| 395 | + | .file-side li.here a { background: color-mix(in srgb, var(--acc) 16%, transparent); font-weight: 600; box-shadow: inset 3px 0 0 var(--acc); } |
|
| 396 | + | .file-side .glyph { color: var(--dim); font-size: 10px; } |
|
| 397 | + | .file-main { min-width: 0; overflow-x: auto; } |
|
| 398 | + | .linebar { display: flex; align-items: center; gap: 8px; padding: 6px 12px; border-bottom: 1px solid var(--line); background: color-mix(in srgb, var(--acc) 8%, var(--panel)); } |
|
| 399 | + | .linebar[hidden] { display: none; } |
|
| 400 | + | .linebar [data-linebar-label] { margin-right: auto; } |
|
| 401 | + | table.code td.blame { box-shadow: inset 3px 0 0 color-mix(in srgb, var(--acc) calc(var(--age, 0) * 10%), transparent); } |
|
| 402 | + | table.code td.blame.age-0 { --age: 1; } table.code td.blame.age-1 { --age: 2; } table.code td.blame.age-2 { --age: 3; } |
|
| 403 | + | table.code td.blame.age-3 { --age: 4; } table.code td.blame.age-4 { --age: 5; } table.code td.blame.age-5 { --age: 6; } |
|
| 404 | + | table.code td.blame.age-6 { --age: 7; } table.code td.blame.age-7 { --age: 8; } table.code td.blame.age-8 { --age: 9; } |
|
| 405 | + | table.code td.blame.age-9 { --age: 10; } |
|
| 406 | + | table.code td.blame .before { color: var(--dim); margin-left: 4px; } |
|
| 407 | + | table.code td.blame .before:hover { color: var(--acc); text-decoration: none; } |
|
| 408 | + | @media (max-width: 900px) { |
|
| 409 | + | .file-layout { grid-template-columns: minmax(0, 1fr); } |
|
| 410 | + | .file-side { display: none; } |
|
| 411 | + | } |
modified templates/tree.html +3 −3
| 1 | 1 | {% extends "base.html" %} |
|
| 2 | 2 | {% import "repo_tabs.html" as m %} |
|
| 3 | - | {% block title %}{{ t.path }} at {{ t.target.label|short }} · {{ repo.owner }}/{{ repo.name }}{% endblock %} |
|
| 4 | - | {% block bodyattrs %} data-repo="{{ repo.url }}" data-ref="{{ t.target.label }}"{% endblock %} |
|
| 3 | + | {% block title %}{{ t.path }} at {{ t.target.short_label() }} · {{ repo.owner }}/{{ repo.name }}{% endblock %} |
|
| 4 | + | {% block bodyattrs %} data-repo="{{ repo.url }}" data-ref="{{ t.target.label }}"{% if let Some(p) = t.target.permalink() %} data-permalink="{{ repo.url }}/tree/{{ p }}/{{ t.path }}"{% endif %}{% endblock %} |
|
| 5 | 5 | {% block tabs %}{% call m::tabs(repo, "code") %}{% endblock %} |
|
| 6 | 6 | {% block mainclass %} class="wide"{% endblock %} |
|
| 7 | 7 | {% block content %} |
|
| 8 | 8 | <div class="bar"> |
|
| 9 | - | <span class="ref px">{% if t.target.kind == crate::repos::view::RefKind::Change %}# {{ t.target.label|short }}{% else %}◆ {{ t.target.label }}{% endif %}</span> |
|
| 9 | + | <span class="ref px">{% if t.target.kind == crate::repos::view::RefKind::Change %}# {{ t.target.short_label() }}{% else %}◆ {{ t.target.label }}{% endif %}</span> |
|
| 10 | 10 | <span class="crumbs"><a href="{{ repo.url }}/tree/{{ t.target.label }}">{{ repo.name }}</a>{% for (n, p) in t.crumbs %} / <a href="{{ repo.url }}/tree/{{ t.target.label }}/{{ p }}">{{ n }}</a>{% endfor %}</span> |
|
| 11 | 11 | <a class="go small" href="{{ repo.url }}/changes/{{ t.target.label }}{% if !t.path.is_empty() %}?path={{ t.path }}{% endif %}">history</a> |
|
| 12 | 12 | {% if repo.can(crate::auth::Access::Write) %}<a class="go small" href="{{ repo.url }}/newfile/{{ t.target.label }}?dir={{ t.path }}">new file</a>{% endif %} |
|
| … | 4 unchanged lines |