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); } |