OXYGENHUB

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