OXYGENHUB

Repository overview: the heads strip lines up with the main column, clone moves behind a button beside search, Contributors and Languages boxes, and a public, internal or private badge by the name 83d5052bf76c

closes #18
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-09-28 15:52 UTC
Signature
✔ signed by Andy McCall
Hash
83d5052bf76ca902606bed32c9007351a48e48ba5d28bec224fab61af47148d4
Depends on
07b1a5bbf481 19bb31ff1f5c 3f4b0ea6eabf 64caed18236a 6ae41081ccd3 756fe44a0f4d 9756c67d3aec 9ff27da517fe d23464c0ac60
Depended on by
351cf57e5608 84e3c09656b2 a10446c68043 a7aa76ac52d7 7b17d570d7ee ffb67a81188c 7f283f8e8b97 c9aacc0e7dc1 206c1fc1b06a d0fbea2eb46e 8933faff2043 67b24adbf35a 6ad368c5958e
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
16 atoms, 0 tree operations, 6 files

Browse the tree at this change

modified crates/hub/src/repos/view.rs +182 −0
…
734 unchanged lines
735 735
    pub conflicted: bool,
736 736
    pub license: Option<String>,
737 737
    pub policy: PolicyBadge,
738 +
    pub contributors: Vec<Contributor>,
739 +
    pub languages: Vec<LanguageShare>,
740 +
}
741 +
742 +
/// Someone who authored changes in a state (hub spec §6.4).
743 +
#[derive(Clone, Debug, PartialEq, Eq)]
744 +
pub struct Contributor {
745 +
    pub name: String,
746 +
    /// The identity key (hex), empty for authors imported from Git.
747 +
    pub key: String,
748 +
    pub changes: usize,
749 +
    /// The hub account the key is linked to, filled by the web layer.
750 +
    pub account: Option<String>,
751 +
}
752 +
753 +
/// One language's share of a state's code, by bytes.
754 +
#[derive(Clone, Debug, PartialEq)]
755 +
pub struct LanguageShare {
756 +
    pub name: String,
757 +
    pub bytes: u64,
758 +
    pub percent: f64,
759 +
    pub colour: String,
760 +
}
761 +
762 +
/// Authors of the changes of a state, most changes first.
763 +
pub fn contributors(repo: &Repo, state: &oxy_core::state::State) -> Vec<Contributor> {
764 +
    let mut by: HashMap<String, Contributor> = HashMap::new();
765 +
    for (_, meta) in state.changes() {
766 +
        let a = author(repo, &meta.author, &meta.message);
767 +
        let id = if a.key.is_empty() { format!("git:{}", a.email.to_lowercase()) } else { a.key.clone() };
768 +
        by.entry(id).or_insert_with(|| Contributor { name: a.name.clone(), key: a.key.clone(), changes: 0, account: None }).changes += 1;
769 +
    }
770 +
    let mut out: Vec<Contributor> = by.into_values().collect();
771 +
    out.sort_by(|a, b| b.changes.cmp(&a.changes).then(a.name.cmp(&b.name)));
772 +
    out
773 +
}
774 +
775 +
/// The language of a path for the languages bar: an extension table for
776 +
/// what syntect lacks, then syntect; `None` for prose, data, lock files and
777 +
/// vendored or generated paths, which Linguist leaves out too.
778 +
pub fn language_of(path: &str) -> Option<&'static str> {
779 +
    let lower = path.to_ascii_lowercase();
780 +
    let name = lower.rsplit('/').next().unwrap_or(&lower);
781 +
    let skip_dirs = ["vendor/", "node_modules/", "target/", "dist/", "third_party/", ".git/"];
782 +
    if skip_dirs.iter().any(|d| lower.starts_with(d) || lower.contains(&format!("/{d}"))) {
783 +
        return None;
784 +
    }
785 +
    if name.ends_with(".lock")
786 +
        || name == "package-lock.json"
787 +
        || name.ends_with(".min.js")
788 +
        || name.ends_with(".min.css")
789 +
        || name.ends_with(".d.ts")
790 +
    {
791 +
        return None;
792 +
    }
793 +
    let ext = name.rsplit_once('.').map(|(_, e)| e).unwrap_or("");
794 +
    let by_ext = match ext {
795 +
        "s" | "asm" | "a65" | "a80" | "z80" | "inc" | "65s" => Some("Assembly"),
796 +
        "toml" => Some("TOML"),
797 +
        "ts" | "tsx" | "mts" | "cts" => Some("TypeScript"),
798 +
        "kdl" => Some("KDL"),
799 +
        "sh" | "bash" | "zsh" => Some("Shell"),
800 +
        "h" => Some("C"),
801 +
        "hpp" | "hh" | "cc" | "cxx" => Some("C++"),
802 +
        "vue" => Some("Vue"),
803 +
        "svelte" => Some("Svelte"),
804 +
        "zig" => Some("Zig"),
805 +
        "nix" => Some("Nix"),
806 +
        "md" | "markdown" | "txt" | "rst" | "adoc" | "json" | "csv" | "svg" | "xml" => return None,
807 +
        _ => None,
808 +
    };
809 +
    if by_ext.is_some() {
810 +
        return by_ext;
811 +
    }
812 +
    let lang = crate::render::highlight::language(path)?;
813 +
    Some(match lang {
814 +
        "Bourne Again Shell (bash)" | "Shell-Unix-Generic" => "Shell",
815 +
        "Plain Text" | "Markdown" | "MultiMarkdown" | "JSON" | "Git Ignore" | "Git Attributes" | "Git Config" => return None,
816 +
        l => l,
817 +
    })
818 +
}
819 +
820 +
/// Linguist's colours for common languages; a stable colour otherwise.
821 +
pub fn language_colour(name: &str) -> String {
822 +
    let known = match name {
823 +
        "Rust" => "#dea584",
824 +
        "Python" => "#3572a5",
825 +
        "Shell" => "#89e051",
826 +
        "Makefile" => "#427819",
827 +
        "C" => "#555555",
828 +
        "C++" => "#f34b7d",
829 +
        "C#" => "#178600",
830 +
        "Assembly" => "#6e4c13",
831 +
        "JavaScript" => "#f1e05a",
832 +
        "TypeScript" => "#3178c6",
833 +
        "HTML" => "#e34c26",
834 +
        "CSS" => "#663399",
835 +
        "Go" => "#00add8",
836 +
        "Java" => "#b07219",
837 +
        "TOML" => "#9c4221",
838 +
        "YAML" => "#cb171e",
839 +
        "SQL" => "#e38c00",
840 +
        "Lua" => "#000080",
841 +
        "Ruby" => "#701516",
842 +
        "PHP" => "#4f5d95",
843 +
        "Haskell" => "#5e5086",
844 +
        "KDL" => "#ffb3b3",
845 +
        "Dockerfile" => "#384d54",
846 +
        "Vue" => "#41b883",
847 +
        "Zig" => "#ec915c",
848 +
        "Nix" => "#7e7eff",
849 +
        _ => "",
850 +
    };
851 +
    if !known.is_empty() {
852 +
        return known.to_string();
853 +
    }
854 +
    let h = blake3::hash(name.as_bytes());
855 +
    let b = h.as_bytes();
856 +
    format!("#{:02x}{:02x}{:02x}", 64 + b[0] / 2, 64 + b[1] / 2, 64 + b[2] / 2)
738 857
}
739 858
859 +
/// Languages of a state's files by bytes, largest first; under 1% folds into "Other".
860 +
pub fn languages(snapshot: &Snapshot) -> Vec<LanguageShare> {
861 +
    let mut by: HashMap<&'static str, u64> = HashMap::new();
862 +
    for (path, f) in &snapshot.files {
863 +
        if let FileContent::Text { bytes, .. } = &f.content {
864 +
            if let Some(l) = language_of(path) {
865 +
                *by.entry(l).or_default() += bytes.len() as u64;
866 +
            }
867 +
        }
868 +
    }
869 +
    let total: u64 = by.values().sum();
870 +
    if total == 0 {
871 +
        return vec![];
872 +
    }
873 +
    let mut list: Vec<(&str, u64)> = by.into_iter().collect();
874 +
    list.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(b.0)));
875 +
    let mut out: Vec<LanguageShare> = Vec::new();
876 +
    let mut other = 0u64;
877 +
    for (name, bytes) in list {
878 +
        if (bytes as f64) * 100.0 / (total as f64) < 1.0 || out.len() >= 8 {
879 +
            other += bytes;
880 +
        } else {
881 +
            out.push(LanguageShare {
882 +
                name: name.to_string(),
883 +
                bytes,
884 +
                percent: bytes as f64 * 100.0 / total as f64,
885 +
                colour: language_colour(name),
886 +
            });
887 +
        }
888 +
    }
889 +
    if other > 0 {
890 +
        out.push(LanguageShare {
891 +
            name: "Other".into(),
892 +
            bytes: other,
893 +
            percent: other as f64 * 100.0 / total as f64,
894 +
            colour: "#8592ad".into(),
895 +
        });
896 +
    }
897 +
    out
898 +
}
899 +
740 900
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
741 901
pub enum PolicyBadge {
742 902
    None,
…
78 unchanged lines
821 981
        conflicted,
822 982
        license,
823 983
        policy: policy_badge(repo),
984 +
        contributors: contributors(repo, state),
985 +
        languages: languages(snapshot),
824 986
    })
825 987
}
826 988
…
225 unchanged lines
1052 1214
        }
1053 1215
    }
1054 1216
    Ok(Trust { policy, verification: verification(repo, cache)?, hub_key: hub_key.to_hex(), hub_delegated_by: delegated })
1217 +
}
1218 +
1219 +
#[cfg(test)]
1220 +
mod language_tests {
1221 +
    use super::*;
1222 +
1223 +
    #[test]
1224 +
    fn languages_are_detected_and_prose_is_left_out() {
1225 +
        assert_eq!(language_of("src/main.rs"), Some("Rust"));
1226 +
        assert_eq!(language_of("boot/start.s"), Some("Assembly"));
1227 +
        assert_eq!(language_of("build.sh"), Some("Shell"));
1228 +
        assert_eq!(language_of("Makefile"), Some("Makefile"));
1229 +
        assert_eq!(language_of("Cargo.toml"), Some("TOML"));
1230 +
        assert_eq!(language_of("README.md"), None);
1231 +
        assert_eq!(language_of("Cargo.lock"), None);
1232 +
        assert_eq!(language_of("web/node_modules/x/index.js"), None);
1233 +
        assert_eq!(language_of("vendor/lib.c"), None);
1234 +
        assert_eq!(language_colour("Rust"), "#dea584");
1235 +
        assert_eq!(language_colour("Befunge"), language_colour("Befunge"), "stable");
1236 +
    }
1055 1237
}
1056 1238
1057 1239
#[cfg(test)]
…
60 unchanged lines
modified crates/hub/src/web/repo.rs +7 −1
…
8 unchanged lines
9 9
use axum::extract::{Path, Query, State};
10 10
use axum::response::{IntoResponse, Response};
11 11
use serde::Deserialize;
12 +
use std::collections::HashMap;
12 13
13 14
#[derive(Template)]
14 15
#[template(path = "overview.html")]
…
85 unchanged lines
100 101
    let shell = Shell::new(&app, &format!("/{owner}/{name}"), &viewer);
101 102
    let (repo, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
102 103
    let default = row.default_bookmark.clone();
103 -
    let o =
104 +
    let mut o =
104 105
        app.repos.read(&repo, move |r, cache| view::overview(r, cache, &default)).await.map_err(|e| Error::from_repo(shell.clone(), e))?;
106 +
    // Contributors link to the hub accounts their keys are linked to.
107 +
    let linked: HashMap<String, String> = crate::db::accounts::identity_accounts(&app.db).await.unwrap_or_default().into_iter().collect();
108 +
    for c in &mut o.contributors {
109 +
        c.account = linked.get(&c.key).cloned();
110 +
    }
105 111
    let rs = RepoShell::new(&app, &row, "code").with_role(role);
106 112
    let readme_html = o.readme_html(&rs, &o.tree.target.label);
107 113
    let watching = match viewer.account() {
…
335 unchanged lines
modified crates/hub/tests/browse.rs +4 −0
…
128 unchanged lines
129 129
    assert!(o.contains("href=\"/andy/demo/file/main/docs/guide.md\""), "README link rebased: {o}");
130 130
    assert!(o.contains(&format!("oxy clone {}/andy/demo", h.base)));
131 131
    assert!(o.contains("2 changes"));
132 +
    // Overview: clone popover, contributors, languages, visibility badge.
133 +
    assert!(o.contains("class=\"clone-pop\"") && o.contains("value=\"oxy clone "), "clone behind a button");
134 +
    assert!(o.contains("<h3>Contributors") && o.contains("<h3>Languages</h3>") && o.contains("<strong>Rust</strong>"), "{o}");
135 +
    assert!(o.contains("class=\"vis vis-public\""));
132 136
    // Landmarks, skip link, current tab (hub spec §6.13).
133 137
    assert!(o.contains("<a class=\"skip\" href=\"#main\">") && o.contains("<main id=\"main\""));
134 138
    assert!(o.contains("aria-label=\"Site\"") && o.contains("aria-label=\"Repository\"") && o.contains("aria-label=\"Account\""));
…
146 unchanged lines
modified templates/overview.html +22 −3
…
3 unchanged lines
4 4
{% block bodyattrs %} data-repo="{{ repo.url }}" data-ref="{{ repo.default_bookmark }}"{% endblock %}
5 5
{% block tabs %}{% call m::tabs(repo, "code") %}{% endblock %}
6 6
{% block content %}
7 -
{% if o.heads > 1 %}<div class="strip warn">{{ repo.default_bookmark }} has {{ o.heads }} heads; the tree below is their union. <a href="{{ repo.url }}/bookmarks">Bookmarks</a></div>{% endif %}
8 -
{% if o.conflicted %}<div class="strip bad">This state carries conflicts. <a href="{{ repo.url }}/conflicts">See them</a></div>{% endif %}
9 7
<div class="side-layout rev">
10 8
  <div>
9 +
    {% if o.heads > 1 %}<div class="strip warn">{{ repo.default_bookmark }} has {{ o.heads }} heads; the tree below is their union. <a href="{{ repo.url }}/bookmarks">Bookmarks</a></div>{% endif %}
10 +
    {% if o.conflicted %}<div class="strip bad">This state carries conflicts. <a href="{{ repo.url }}/conflicts">See them</a></div>{% endif %}
11 11
    <div class="bar">
12 12
      <span class="ref px"><a href="{{ repo.url }}/bookmarks">◆ {{ repo.default_bookmark }}</a></span>
13 13
      <a class="go small" href="{{ repo.url }}/changes/{{ repo.default_bookmark }}">{{ o.changes|plural("change", "changes") }}</a>
14 14
      {% if repo.can(crate::auth::Access::Write) %}<a class="go small" href="{{ repo.url }}/newfile/{{ repo.default_bookmark }}">new file</a>{% endif %}
15 15
      <form class="find inline-find" method="get" action="{{ repo.url }}/search"><input type="search" name="q" placeholder="Search code…"></form>
16 -
      <div class="clone top-clone"><code title="oxy clone {{ repo.clone_url }}">oxy clone {{ repo.clone_url }}</code><button class="small" data-copy="oxy clone {{ repo.clone_url }}">copy</button></div>
16 +
      <details class="clone-pop">
17 +
        <summary class="go small">⤓ clone</summary>
18 +
        <div class="clone-panel" role="group" aria-label="Clone this repository">
19 +
          <label for="clone-cmd" class="small mute">Clone with oxy</label>
20 +
          <div class="clone"><input id="clone-cmd" type="text" readonly value="oxy clone {{ repo.clone_url }}" onfocus="this.select()" onclick="this.select()"><button type="button" class="small" data-copy="oxy clone {{ repo.clone_url }}">copy</button></div>
21 +
          <p class="small mute">The URL alone: <code>{{ repo.clone_url }}</code></p>
22 +
        </div>
23 +
      </details>
17 24
    </div>
18 25
    {% if let Some(c) = o.latest %}<p class="small mute latest-line">latest <a href="{{ repo.url }}/change/{{ c.hash }}">{{ c.title }}</a> · {{ c.when_ms|ago }}</p>{% endif %}
19 26
    {% if let Some(q) = quickstart %}
…
18 unchanged lines
38 45
    {% if fork.forks > 0 %}<a class="small" href="{{ repo.url }}/forks">{{ fork.forks|plural("fork", "forks") }}</a>{% endif %}
39 46
    </div>
40 47
    {% if let Some(up) = fork.upstream %}{% if shell.user.is_some() %}<a class="go small" href="/{{ up.owner }}/{{ up.name }}/proposals/new?source={{ repo.owner }}/{{ repo.name }}:{{ repo.default_bookmark }}">propose to {{ up.owner }}/{{ up.name }}</a>{% endif %}{% endif %}
48 +
    {% if !o.contributors.is_empty() %}
49 +
    <h3>Contributors <span class="count-chip">{{ o.contributors.len() }}</span></h3>
50 +
    <ul class="plain contributors">{% for c in o.contributors.iter().take(10) %}
51 +
      <li>{% if let Some(u) = c.account %}<a href="/{{ u }}"><img class="avatar sm" src="/avatar/{{ u }}" alt="" width="20" height="20"> {{ u }}</a> <span class="mute small">{{ c.name }}</span>{% else %}<span class="avatar-none" aria-hidden="true">○</span> {{ c.name }}{% endif %} <span class="mute small" title="changes">· {{ c.changes }}</span></li>
52 +
    {% endfor %}</ul>
53 +
    {% if o.contributors.len() > 10 %}<p class="small mute">and {{ o.contributors.len() - 10 }} more</p>{% endif %}
54 +
    {% endif %}
55 +
    {% if !o.languages.is_empty() %}
56 +
    <h3>Languages</h3>
57 +
    <div class="lang-bar" role="img" aria-label="{% for l in o.languages %}{{ l.name }} {{ "{:.1}"|format(l.percent) }}%{% if !loop.last %}, {% endif %}{% endfor %}">{% for l in o.languages %}<span style="width: {{ "{:.3}"|format(l.percent) }}%; background: {{ l.colour }}" title="{{ l.name }} {{ "{:.1}"|format(l.percent) }}%"></span>{% endfor %}</div>
58 +
    <ul class="plain langs">{% for l in o.languages %}<li><span class="lang-dot" style="background: {{ l.colour }}" aria-hidden="true"></span> <strong>{{ l.name }}</strong> <span class="mute">{{ "{:.1}"|format(l.percent) }}%</span></li>{% endfor %}</ul>
59 +
    {% endif %}
41 60
    <h3>Bookmarks</h3>
42 61
    <ul class="plain">{% for b in o.bookmarks %}<li><a href="{{ repo.url }}/tree/{{ b.name }}">{{ b.name }}</a>{% if b.heads.len() > 1 %} <span class="tag warn">{{ b.heads.len() }} heads</span>{% endif %}{% if b.conflicted %} <span class="tag bad">conflicts</span>{% endif %}</li>{% endfor %}</ul>
43 62
    {% if let Some(rel) = latest_release %}<h3>Latest release</h3><p><a href="{{ repo.url }}/releases/tag/{{ rel.tag }}">{{ rel.title }}</a> <span class="small mute">{{ rel.created_ms|ago_i }}</span></p>{% endif %}
…
12 unchanged lines
modified templates/repo_tabs.html +1 −1
1 1
{% macro tabs(repo, tab) %}
2 2
<nav class="tabs" aria-label="Repository">
3 3
  <div class="tabs-in">
4 -
    <span class="crumb"><a href="/{{ repo.owner }}">{{ repo.owner }}</a> / <a href="{{ repo.url }}"><strong>{{ repo.name }}</strong></a>{% if repo.visibility != "public" %} <span class="tag">{{ repo.visibility }}</span>{% endif %}</span>
4 +
    <span class="crumb"><a href="/{{ repo.owner }}">{{ repo.owner }}</a> / <a href="{{ repo.url }}"><strong>{{ repo.name }}</strong></a> <span class="vis vis-{{ repo.visibility }}" title="{% if repo.visibility == "public" %}Anyone can see this repository{% else if repo.visibility == "internal" %}Anyone signed in to this hub can see this repository{% else %}Only people given access can see this repository{% endif %}"><span aria-hidden="true">{% if repo.visibility == "public" %}◯{% else if repo.visibility == "internal" %}◐{% else %}●{% endif %}</span> {{ repo.visibility }}</span></span>
5 5
    <a href="{{ repo.url }}"{% if tab == "code" %} class="on" aria-current="page"{% endif %}>Code</a>
6 6
    <a href="{{ repo.url }}/changes/{{ repo.default_bookmark }}"{% if tab == "changes" %} class="on" aria-current="page"{% endif %}>Changes</a>
7 7
    <a href="{{ repo.url }}/proposals"{% if tab == "proposals" %} class="on" aria-current="page"{% endif %}>Proposals{% if repo.open_proposals > 0 %} <span class="count-chip">{{ repo.open_proposals }}</span>{% endif %}</a>
…
67 unchanged lines
modified templates/style.css +23 −0
…
517 unchanged lines
518 518
code.hook-url { overflow-wrap: anywhere; word-break: break-all; }
519 519
li.hook p { margin: 4px 0; }
520 520
li.hook .hook-edit { margin-top: 4px; }
521 +
522 +
/* Repository overview: clone popover, contributors, languages, visibility. */
523 +
.clone-pop { position: relative; }
524 +
.clone-pop > summary { list-style: none; cursor: pointer; }
525 +
.clone-pop > summary::-webkit-details-marker { display: none; }
526 +
.clone-pop[open] > summary { background: var(--soft); }
527 +
.clone-panel { position: absolute; right: 0; top: calc(100% + 6px); z-index: 20; width: min(440px, 88vw); background: var(--panel); border: 1px solid var(--line); box-shadow: 0 6px 24px rgba(0,0,0,.25); padding: 10px 12px; display: flex; flex-direction: column; gap: 6px; }
528 +
.clone-panel .clone input { flex: 1; min-width: 0; margin: 0; font-family: var(--mono, monospace); font-size: 12px; }
529 +
.clone-panel p { margin: 0; overflow-wrap: anywhere; }
530 +
.bar .inline-find { flex: 1 1 200px; }
531 +
ul.contributors li { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; margin: 4px 0; }
532 +
ul.contributors img.avatar.sm { width: 20px; height: 20px; vertical-align: middle; }
533 +
.avatar-none { display: inline-block; width: 20px; text-align: center; color: var(--mute); }
534 +
.lang-bar { display: flex; height: 8px; overflow: hidden; background: var(--soft); margin: 6px 0 8px; }
535 +
.lang-bar > span { display: block; height: 100%; }
536 +
.lang-bar > span + span { margin-left: 1px; }
537 +
ul.langs { display: flex; flex-wrap: wrap; gap: 4px 14px; font-size: 13px; }
538 +
ul.langs li { margin: 0; }
539 +
.lang-dot { display: inline-block; width: 9px; height: 9px; border-radius: 50%; vertical-align: middle; }
540 +
.vis { display: inline-flex; align-items: center; gap: 4px; font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: .06em; padding: 1px 8px; border: 1px solid var(--line); border-radius: 10px; margin-left: 8px; vertical-align: middle; }
541 +
.vis-public { color: var(--ok); border-color: color-mix(in srgb, var(--ok) 45%, transparent); }
542 +
.vis-internal { color: var(--warn); border-color: color-mix(in srgb, var(--warn) 45%, transparent); }
543 +
.vis-private { color: var(--bad); border-color: color-mix(in srgb, var(--bad) 45%, transparent); }