OXYGENHUB

The store as its provider's icon; a tidier Projects page 7b26e6118bc0

A repository kept in its owner's bucket shows the provider's icon in the
header (Google Cloud Storage, Amazon S3 or Azure Blob Storage) instead of
a 'bucket' tag. Hovered, focused or tapped, it opens a card: the
provider, and for the repository's admins the bucket (or account and
container), the prefix, the region and the URL, with a link to Settings
> Storage and its last check; others see only that the data is in the
owner's own storage. CSS only. The icons are drawn for the hub in each
provider's colour, not their logos.

The Projects page has a filter box (title or description, ?q=) and the
New project button in one bar, Open and Closed tabs with their counts
(?state=closed), and a row per project with a board icon, its title, a
private badge, '#N · updated' and its description. The new-project
form opens from the button, above the list, instead of trailing it.

closes #123, closes #124
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-10-10 18:27 UTC
Signature
✔ signed by Andy McCall
Hash
7b26e6118bc0aa60ea3f2495fd7cf09f50f30da63472a67db06990408098b2d9
Depends on
18e381ea88c2 1f78655a0466 554ccacfc123 66fd9d6b1452 8ddebaa7e856 a024e800af4e c987c86513df d23464c0ac60 e1ec9fbf538e
Depended on by
c7067c7bda56 7f283f8e8b97 c8df33a6829f
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.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
28 atoms, 0 tree operations, 8 files

Browse the tree at this change

modified crates/hub/src/storage.rs +57 −0
…
55 unchanged lines
56 56
}
57 57
58 58
/// Where a repository's data lives, when it is not on this hub's disk.
59 +
/// What the repository header shows of a store (#123): which provider,
60 +
/// and, for the repository's admins, where in it.
61 +
#[derive(Clone, Debug, PartialEq)]
62 +
pub struct StoreBadge {
63 +
    /// `gcs`, `s3` or `azure`: picks the icon.
64 +
    pub kind: &'static str,
65 +
    pub provider: &'static str,
66 +
    /// The bucket, or for Azure the storage account and container.
67 +
    pub place: String,
68 +
    pub prefix: String,
69 +
    pub region: Option<String>,
70 +
    /// The URL as stored, query and all.
71 +
    pub url: String,
72 +
}
73 +
74 +
impl StoreBadge {
75 +
    /// The parts of a store URL; `None` for a scheme the hub does not
76 +
    /// keep repositories on.
77 +
    pub fn of(url: &str) -> Option<StoreBadge> {
78 +
        let (scheme, rest) = url.split_once("://")?;
79 +
        let (path, query) = rest.split_once('?').unwrap_or((rest, ""));
80 +
        let region = query.split('&').find_map(|kv| kv.strip_prefix("region=")).filter(|r| !r.is_empty()).map(str::to_string);
81 +
        let mut parts = path.trim_matches('/').splitn(if scheme == "az" { 3 } else { 2 }, '/');
82 +
        let (kind, provider, place) = match scheme {
83 +
            "gs" => ("gcs", "Google Cloud Storage", parts.next()?.to_string()),
84 +
            "s3" => ("s3", "Amazon S3", parts.next()?.to_string()),
85 +
            "az" => ("azure", "Azure Blob Storage", format!("{} / {}", parts.next()?, parts.next()?)),
86 +
            _ => return None,
87 +
        };
88 +
        Some(StoreBadge { kind, provider, place, prefix: parts.next().unwrap_or("").to_string(), region, url: url.to_string() })
89 +
    }
90 +
}
91 +
59 92
pub fn location(app: &Shared, owner: &str, name: &str) -> Option<String> {
60 93
    let dir = app.repos.dir(&crate::repos::RepoName::new(owner, name)?);
61 94
    KNOWN.read().ok()?.as_ref()?.urls.get(&dir).cloned()
…
280 unchanged lines
342 375
        assert!(strict.permits("acme", Some("s3://acme-london/repos/x?region=eu-west-2")).is_ok());
343 376
        let e = strict.permits("acme", Some("s3://acme-virginia/x")).unwrap_err();
344 377
        assert!(e.contains("allows only these stores"), "{e}");
378 +
    }
379 +
}
380 +
381 +
#[cfg(test)]
382 +
mod badge_tests {
383 +
    use super::StoreBadge;
384 +
385 +
    #[test]
386 +
    fn a_store_url_names_its_provider_place_prefix_and_region() {
387 +
        let g = StoreBadge::of("gs://doomsdayone-oxy-repos/repos/tetris").unwrap();
388 +
        assert_eq!(
389 +
            (g.kind, g.provider, g.place.as_str(), g.prefix.as_str(), g.region),
390 +
            ("gcs", "Google Cloud Storage", "doomsdayone-oxy-repos", "repos/tetris", None)
391 +
        );
392 +
        let s = StoreBadge::of("s3://my-bucket/repos/name?region=eu-west-2").unwrap();
393 +
        assert_eq!(
394 +
            (s.kind, s.place.as_str(), s.prefix.as_str(), s.region.as_deref()),
395 +
            ("s3", "my-bucket", "repos/name", Some("eu-west-2"))
396 +
        );
397 +
        let a = StoreBadge::of("az://account/container/repos/x").unwrap();
398 +
        assert_eq!((a.kind, a.place.as_str(), a.prefix.as_str()), ("azure", "account / container", "repos/x"));
399 +
        assert_eq!(StoreBadge::of("s3://bucket").unwrap().prefix, "");
400 +
        assert!(StoreBadge::of("dir:///tmp/x").is_none());
401 +
        assert!(StoreBadge::of("az://only-account").is_none());
345 402
    }
346 403
}
modified crates/hub/src/web/mod.rs +4 −3
…
408 unchanged lines
409 409
    pub archived: bool,
410 410
    /// Whether the repository has a wiki (§6.11).
411 411
    pub wiki: bool,
412 -
    /// The bucket its data lives in, when not this hub's disk (#96).
413 -
    pub stored_in: Option<String>,
412 +
    /// The bucket its data lives in, when not this hub's disk (#96), as
413 +
    /// the header's provider icon shows it (#123).
414 +
    pub stored_in: Option<crate::storage::StoreBadge>,
414 415
    /// Whether it has coverage reports, for the Coverage tab (#113).
415 416
    pub coverage: bool,
416 417
}
…
14 unchanged lines
431 432
            open_proposals: row.open_proposals,
432 433
            archived: row.archived,
433 434
            wiki: crate::repos::RepoName::new(&row.owner, &row.name).is_some_and(|n| app.wikis.exists(&n)),
434 -
            stored_in: crate::storage::location(app, &row.owner, &row.name),
435 +
            stored_in: crate::storage::location(app, &row.owner, &row.name).and_then(|u| crate::storage::StoreBadge::of(&u)),
435 436
            coverage: crate::coverage::has_reports(app, row.id),
436 437
        }
437 438
    }
…
277 unchanged lines
modified crates/hub/src/web/projects.rs +57 −7
…
6 unchanged lines
7 7
use crate::db::{self, projects as pdb};
8 8
use crate::Shared;
9 9
use askama::Template;
10 -
use axum::extract::{Form, Path, State};
10 +
use axum::extract::{Form, Path, Query, State};
11 11
use axum::response::{IntoResponse, Redirect, Response};
12 12
use serde::Deserialize;
13 13
…
35 unchanged lines
49 49
pub struct ProjectsPage {
50 50
    shell: Shell,
51 51
    owner: String,
52 +
    /// The projects shown: open or closed as `closed` says, matching `q`.
52 53
    projects: Vec<pdb::Project>,
53 54
    can_edit: bool,
54 55
    error: Option<String>,
55 56
    /// On your own page, the organisations you belong to: their boards
56 57
    /// are one click away, and where a board for one is made (#119).
57 58
    orgs: Vec<String>,
59 +
    /// The title filter, and whether the closed ones are shown (#124).
60 +
    q: String,
61 +
    closed: bool,
62 +
    open_count: usize,
63 +
    closed_count: usize,
58 64
}
59 65
66 +
impl ProjectsPage {
67 +
    /// The page for `all` the projects the viewer may see: counted open
68 +
    /// and closed among those matching `q`, then the ones asked for.
69 +
    #[allow(clippy::too_many_arguments)]
70 +
    fn build(
71 +
        shell: Shell,
72 +
        owner: String,
73 +
        all: Vec<pdb::Project>,
74 +
        can_edit: bool,
75 +
        error: Option<String>,
76 +
        orgs: Vec<String>,
77 +
        q: Option<String>,
78 +
        closed: bool,
79 +
    ) -> ProjectsPage {
80 +
        let q = q.unwrap_or_default().trim().to_string();
81 +
        let needle = q.to_lowercase();
82 +
        let matching: Vec<pdb::Project> = all
83 +
            .into_iter()
84 +
            .filter(|p| needle.is_empty() || p.title.to_lowercase().contains(&needle) || p.description.to_lowercase().contains(&needle))
85 +
            .collect();
86 +
        let closed_count = matching.iter().filter(|p| p.closed).count();
87 +
        let open_count = matching.len() - closed_count;
88 +
        let projects = matching.into_iter().filter(|p| p.closed == closed).collect();
89 +
        ProjectsPage { shell, owner, projects, can_edit, error, orgs, q, closed, open_count, closed_count }
90 +
    }
91 +
}
92 +
93 +
/// `?q=TEXT&state=open|closed` on the Projects page (#124).
94 +
#[derive(Deserialize, Default)]
95 +
pub struct ProjectsQuery {
96 +
    #[serde(default)]
97 +
    q: Option<String>,
98 +
    #[serde(default)]
99 +
    state: Option<String>,
100 +
}
101 +
60 102
/// The organisations the viewer belongs to, when `owner` is the viewer.
61 103
async fn own_orgs(app: &Shared, viewer: &Viewer, owner: &str) -> Vec<String> {
62 104
    match viewer.account() {
…
4 unchanged lines
67 109
    }
68 110
}
69 111
70 -
pub async fn list(State(app): State<Shared>, viewer: Viewer, Path(owner): Path<String>) -> Result<Page<ProjectsPage>, Error> {
112 +
pub async fn list(
113 +
    State(app): State<Shared>,
114 +
    viewer: Viewer,
115 +
    Path(owner): Path<String>,
116 +
    Query(query): Query<ProjectsQuery>,
117 +
) -> Result<Page<ProjectsPage>, Error> {
71 118
    let shell = Shell::new(&app, &format!("/projects/{owner}"), &viewer);
72 119
    let r = rights(&app, &shell, &viewer, &owner).await?;
73 120
    let projects = pdb::of_owner(&app.db, &owner)
…
3 unchanged lines
77 124
        .filter(|p| p.public || r.member)
78 125
        .collect();
79 126
    let orgs = own_orgs(&app, &viewer, &owner).await;
80 -
    Ok(Page(ProjectsPage { shell, owner, projects, can_edit: r.edit, error: None, orgs }))
127 +
    let closed = query.state.as_deref() == Some("closed");
128 +
    Ok(Page(ProjectsPage::build(shell, owner, projects, r.edit, None, orgs, query.q, closed)))
81 129
}
82 130
83 131
#[derive(Deserialize)]
…
23 unchanged lines
107 155
    if title.is_empty() || title.len() > 100 {
108 156
        let projects = pdb::of_owner(&app.db, &owner).await.map_err(|e| Error::from_db(shell.clone(), e))?;
109 157
        let orgs = own_orgs(&app, &viewer, &owner).await;
110 -
        return Ok(Page(ProjectsPage {
158 +
        return Ok(Page(ProjectsPage::build(
111 159
            shell,
112 160
            owner,
113 161
            projects,
114 -
            can_edit: true,
115 -
            error: Some("A project needs a title of up to 100 characters.".into()),
162 +
            true,
163 +
            Some("A project needs a title of up to 100 characters.".into()),
116 164
            orgs,
117 -
        })
165 +
            None,
166 +
            false,
167 +
        ))
118 168
        .into_response());
119 169
    }
120 170
    let p = pdb::create(&app.db, &owner, title, f.description.trim(), f.private.is_none())
…
458 unchanged lines
modified crates/hub/tests/issues.rs +12 −0
…
764 unchanged lines
765 765
    let csrf = csrf_of(&list);
766 766
    let r = post(app, "/projects/andy", Some(&andy), &format!("_csrf={csrf}&title=Roadmap&description=Next+steps")).await;
767 767
    assert_eq!(r.location.as_deref(), Some("/projects/andy/1"), "{}", r.body);
768 +
    // The list (#124): a row per project, counted under Open, and a filter.
769 +
    let list = get(app, "/projects/andy", Some(&andy)).await.body;
770 +
    assert!(
771 +
        list.contains(">Roadmap</a>") && list.contains("Next steps") && list.contains("Open <span class=\"count-chip\">1</span>"),
772 +
        "{list}"
773 +
    );
774 +
    let filtered = get(app, "/projects/andy?q=roadm", Some(&andy)).await.body;
775 +
    assert!(filtered.contains(">Roadmap</a>"), "a filter matches part of the title, any case");
776 +
    let none = get(app, "/projects/andy?q=nothing+like+it", Some(&andy)).await.body;
777 +
    assert!(none.contains("No open project matches") && !none.contains(">Roadmap</a>"), "{none}");
778 +
    let closed = get(app, "/projects/andy?state=closed", Some(&andy)).await.body;
779 +
    assert!(closed.contains("No closed projects.") && closed.contains("Closed <span class=\"count-chip\">0</span>"), "{closed}");
768 780
    let board = get(app, "/projects/andy/1", Some(&andy)).await.body;
769 781
    for col in ["Backlog", "Doing", "Done"] {
770 782
        assert!(board.contains(&format!(">{col} <span")), "{col}");
…
83 unchanged lines
modified crates/hub/tests/storage.rs +2 −2
…
119 unchanged lines
120 120
    let (st, body) = new_repo(&h.app, &me, "name=local").await;
121 121
    assert_eq!(st, StatusCode::SEE_OTHER, "{body}");
122 122
    let page = send(&h.app, "GET", "/andy/local", Some(&me), None).await.1;
123 -
    assert!(!page.contains(">bucket</span>"));
123 +
    assert!(!page.contains("class=\"store store-"), "no store icon on a repository on this disk");
124 124
}
125 125
126 126
/// The S3 test bucket's URL with only its region kept, and the profile's keys.
…
69 unchanged lines
196 196
        }
197 197
        // The pages say where it lives; Settings → Storage checks the bucket.
198 198
        let page = send(&h.app, "GET", "/andy/cloudy", Some(&me), None).await.1;
199 -
        assert!(page.contains(">bucket</span>") && page.contains(url.split('?').next().unwrap()), "{page}");
199 +
        assert!(page.contains("class=\"store store-s3\"") && page.contains(url.split('?').next().unwrap()), "{page}");
200 200
        let settings = send(&h.app, "GET", "/andy/cloudy/settings", Some(&me), None).await.1;
201 201
        assert!(settings.contains("id=\"storage\""), "{settings}");
202 202
        let csrf = csrf_of(&settings);
…
59 unchanged lines
modified templates/projects.html +36 −12
1 1
{% extends "base.html" %}
2 2
{% block title %}Projects · {{ owner }}{% endblock %}
3 3
{% block content %}
4 -
<div class="head"><h1>Projects <small><a href="/{{ owner }}">{{ owner }}</a></small></h1>{% if can_edit %}<a class="go" href="#new">+ NEW PROJECT</a>{% endif %}</div>
5 -
{% if let Some(e) = error %}<p class="error">{{ e }}</p>{% endif %}
4 +
<div class="head proj-head">
5 +
  <h1>Projects <small>boards of issues, proposals and notes from <a href="/{{ owner }}">{{ owner }}</a>'s repositories</small></h1>
6 +
</div>
6 7
{% if !orgs.is_empty() %}<p class="small mute">Your organisations' projects: {% for o in orgs %}<a href="/projects/{{ o }}">{{ o }}</a>{% if !loop.last %} · {% endif %}{% endfor %}</p>{% endif %}
7 -
{% if projects.is_empty() %}<p class="empty">No projects yet.{% if can_edit %} A project is a board of issues, proposals and notes from {{ owner }}'s repositories: make one below.{% else %} Only {{ owner }}{% if shell.user.is_some() %} (or its members){% endif %} can make one.{% endif %}</p>{% else %}
8 -
<ul class="rows">
9 -
{% for p in projects %}
10 -
  <li><div><a class="name" href="/projects/{{ owner }}/{{ p.number }}">{{ p.title }}</a>{% if !p.public %} <span class="tag">private</span>{% endif %}{% if p.closed %} <span class="tag dim">closed</span>{% endif %}
11 -
  <p class="small mute">#{{ p.number }}{% if !p.description.is_empty() %} · {{ p.description }}{% endif %} · updated <time title="{{ p.updated_ms|when_i }}">{{ p.updated_ms|ago_i }}</time></p></div></li>
12 -
{% endfor %}
13 -
</ul>
14 -
{% endif %}
8 +
{% if let Some(e) = error %}<p class="error">{{ e }}</p>{% endif %}
9 +
<div class="proj-bar">
10 +
  <form class="proj-filter" method="get" action="/projects/{{ owner }}" role="search">
11 +
    <svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true" focusable="false"><circle cx="6.8" cy="6.8" r="4.6" fill="none" stroke="currentColor" stroke-width="1.6"/><path d="m10.3 10.3 4 4" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg>
12 +
    <input type="search" id="q" name="q" value="{{ q }}" placeholder="Filter projects by title or description" aria-label="Filter projects">
13 +
    {% if closed %}<input type="hidden" name="state" value="closed">{% endif %}
14 +
  </form>
15 +
  {% if can_edit %}<a class="go" href="#new">+ NEW PROJECT</a>{% endif %}
16 +
</div>
15 17
{% if can_edit %}
16 -
<form class="card" id="new" method="post" action="/projects/{{ owner }}">
18 +
<form class="card proj-new{% if error.is_some() %} open{% endif %}" id="new" method="post" action="/projects/{{ owner }}">
17 19
  <input type="hidden" name="_csrf" value="{{ shell.csrf }}">
18 20
  <h2>New project</h2>
19 21
  <label for="title">Title</label><input type="text" id="title" name="title" maxlength="100" required>
20 22
  <label for="description">Description</label><input type="text" id="description" name="description">
21 23
  <div class="choice"><label><input type="checkbox" name="private" value="1"> private (only {% if owner == shell.user.clone().unwrap_or_default() %}you{% else %}members of {{ owner }}{% endif %})</label></div>
22 -
  <button type="submit">CREATE</button>
24 +
  <div class="proj-new-actions"><button type="submit">CREATE</button> <a href="/projects/{{ owner }}">Cancel</a></div>
23 25
  <p class="small mute">It starts with the columns Backlog, Doing and Done.</p>
24 26
</form>
25 27
{% endif %}
28 +
<div class="proj-list">
29 +
  <div class="proj-tabs" role="tablist">
30 +
    <a role="tab" href="/projects/{{ owner }}{% if !q.is_empty() %}?q={{ q|urlencode }}{% endif %}"{% if !closed %} class="on" aria-selected="true"{% endif %}><svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true" focusable="false"><rect x="1.75" y="2.25" width="12.5" height="11.5" rx="1.5" fill="none" stroke="currentColor" stroke-width="1.5"/><path d="M6.25 2.5v11M10.25 2.5v11" stroke="currentColor" stroke-width="1.5"/></svg> Open <span class="count-chip">{{ open_count }}</span></a>
31 +
    <a role="tab" href="/projects/{{ owner }}?state=closed{% if !q.is_empty() %}&amp;q={{ q|urlencode }}{% endif %}"{% if closed %} class="on" aria-selected="true"{% endif %}><svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true" focusable="false"><path d="m3 8.5 3 3 7-7" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></svg> Closed <span class="count-chip">{{ closed_count }}</span></a>
32 +
  </div>
33 +
  {% if projects.is_empty() %}
34 +
  <p class="empty">{% if !q.is_empty() %}No {% if closed %}closed{% else %}open{% endif %} project matches “{{ q }}”.{% else if closed %}No closed projects.{% else if open_count + closed_count == 0 %}No projects yet.{% if can_edit %} A project is a board of issues, proposals and notes from {{ owner }}'s repositories: <a href="#new">make one</a>.{% else %} Only {{ owner }}{% if shell.user.is_some() %} (or its members){% endif %} can make one.{% endif %}{% else %}No open projects.{% endif %}</p>
35 +
  {% else %}
36 +
  <ul class="proj-rows">
37 +
  {% for p in projects %}
38 +
    <li>
39 +
      <svg class="proj-icon" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true" focusable="false"><rect x="1.75" y="2.25" width="12.5" height="11.5" rx="1.5" fill="none" stroke="currentColor" stroke-width="1.5"/><path d="M6.25 2.5v11M10.25 2.5v11" stroke="currentColor" stroke-width="1.5"/></svg>
40 +
      <div class="proj-main">
41 +
        <div class="proj-title"><a class="name" href="/projects/{{ owner }}/{{ p.number }}">{{ p.title }}</a>{% if !p.public %} <span class="vis vis-private">private</span>{% endif %}{% if p.closed %} <span class="tag dim">closed</span>{% endif %}</div>
42 +
        <p class="small mute">#{{ p.number }} · updated <time title="{{ p.updated_ms|when_i }}">{{ p.updated_ms|ago_i }}</time></p>
43 +
        {% if !p.description.is_empty() %}<p class="proj-desc">{{ p.description }}</p>{% endif %}
44 +
      </div>
45 +
    </li>
46 +
  {% endfor %}
47 +
  </ul>
48 +
  {% endif %}
49 +
</div>
26 50
{% endblock %}
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.archived %} <span class="tag warn" title="This repository is archived: read-only">archived</span>{% endif %} <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>{% if let Some(b) = repo.stored_in %} <span class="tag" title="This repository's data lives in {% if repo.can(crate::auth::Access::Admin) %}{{ b }}, {% endif %}its owner's bucket">bucket</span>{% endif %}</span>
4 +
    <span class="crumb"><a href="/{{ repo.owner }}">{{ repo.owner }}</a> / <a href="{{ repo.url }}"><strong>{{ repo.name }}</strong></a>{% if repo.archived %} <span class="tag warn" title="This repository is archived: read-only">archived</span>{% endif %} <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>{% if let Some(b) = repo.stored_in %} <span class="store store-{{ b.kind }}" tabindex="0" aria-label="Kept in its owner's {{ b.provider }}"><svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true" focusable="false">{% if b.kind == "gcs" %}<path d="M4.2 1.5h7.6L15.6 8l-3.8 6.5H4.2L.4 8z" fill="#4285f4"/><rect x="4.6" y="5" width="6.8" height="2" rx=".6" fill="#fff"/><rect x="4.6" y="9" width="6.8" height="2" rx=".6" fill="#fff"/>{% else if b.kind == "s3" %}<path d="M1.8 3.6 3.6 13.2c.2 1 2.2 1.6 4.4 1.6s4.2-.6 4.4-1.6l1.8-9.6z" fill="#7aa116"/><ellipse cx="8" cy="3.6" rx="6.2" ry="2.1" fill="#a4cc4c"/>{% else %}<rect x="1.5" y="2" width="13" height="12" rx="1.6" fill="#0078d4"/><rect x="4" y="5" width="8" height="1.6" fill="#fff"/><rect x="4" y="7.7" width="8" height="1.6" fill="#fff"/><rect x="4" y="10.4" width="5" height="1.6" fill="#fff"/>{% endif %}</svg><span class="store-card" role="tooltip"><strong>{{ b.provider }}</strong>{% if repo.can(crate::auth::Access::Admin) %}<dl><dt>{% if b.kind == "azure" %}Account / container{% else %}Bucket{% endif %}</dt><dd><code>{{ b.place }}</code></dd>{% if !b.prefix.is_empty() %}<dt>Prefix</dt><dd><code>{{ b.prefix }}</code></dd>{% endif %}{% if let Some(r) = b.region %}<dt>Region</dt><dd>{{ r }}</dd>{% endif %}<dt>URL</dt><dd><code>{{ b.url }}</code></dd></dl><a href="{{ repo.url }}/settings#storage">Storage settings and the last check</a>{% else %}<span class="mute">This repository's data is kept in its owner's own {{ b.provider }}, not on this server's disk.</span>{% endif %}</span></span>{% endif %}</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>
…
70 unchanged lines
modified templates/style.css +43 −0
…
856 unchanged lines
857 857
.cov-code tr.cov-hit td.t, .cov-key.cov-hit { background: color-mix(in srgb, var(--ok) 16%, transparent); }
858 858
.cov-code tr.cov-miss td.t, .cov-key.cov-miss { background: color-mix(in srgb, var(--bad) 16%, transparent); }
859 859
.cov-code td.cov-hits { text-align: right; padding: 0 .5em; color: var(--mute); font-size: .85em; white-space: nowrap; user-select: none; }
860 +
861 +
/* Where a repository's data lives, when it is its owner's bucket (#123):
862 +
   the provider's icon, and a card with the details on hover, focus or a
863 +
   tap (it takes focus). */
864 +
.store { position: relative; display: inline-flex; align-items: center; margin-left: 8px; vertical-align: middle; cursor: pointer; border-radius: 3px; }
865 +
.store svg { display: block; }
866 +
.store:focus-visible { outline: 2px solid var(--acc); outline-offset: 2px; }
867 +
.store-card { display: none; position: absolute; top: calc(100% + 8px); left: -6px; z-index: 30; width: max-content; max-width: min(420px, 90vw); background: var(--panel); border: 1px solid var(--line); box-shadow: 0 8px 24px rgba(0, 0, 0, .28); padding: 10px 12px; font-size: 13px; font-weight: 400; line-height: 1.45; white-space: normal; color: var(--ink); cursor: auto; }
868 +
.store-card::before { content: ""; position: absolute; top: -10px; left: 0; right: 0; height: 10px; }
869 +
.store:hover .store-card, .store:focus .store-card, .store:focus-within .store-card { display: block; }
870 +
.store-card strong { display: block; margin-bottom: 4px; }
871 +
.store-card dl { display: grid; grid-template-columns: auto 1fr; gap: 3px 12px; margin: 6px 0 8px; }
872 +
.store-card dt { color: var(--mute); }
873 +
.store-card dd { margin: 0; min-width: 0; }
874 +
.store-card code { word-break: break-all; }
875 +
876 +
/* Projects (#124): a filter and a New project button above a panel of
877 +
   rows with Open and Closed tabs; the form opens from the button. */
878 +
.proj-bar { display: flex; align-items: center; gap: 10px; margin: 0 0 14px; }
879 +
.proj-filter { flex: 1; min-width: 0; display: flex; align-items: center; gap: 8px; margin: 0; padding: 0 10px; border: 1px solid var(--line); background: var(--panel); color: var(--mute); }
880 +
.proj-filter:focus-within { border-color: var(--acc); }
881 +
.proj-filter input { flex: 1; min-width: 0; width: auto; margin: 0; padding: 7px 0; border: 0; background: transparent; color: var(--ink); outline: none; }
882 +
.proj-new { display: none; max-width: 560px; margin: 0 0 16px; }
883 +
.proj-new:target, .proj-new.open { display: block; }
884 +
.proj-new h2 { margin-top: 0; }
885 +
.proj-new-actions { display: flex; align-items: center; gap: 14px; margin-top: 4px; }
886 +
.proj-list { border: 1px solid var(--line); background: var(--panel); }
887 +
.proj-tabs { display: flex; gap: 4px; padding: 8px 12px; background: var(--soft); border-bottom: 1px solid var(--line); }
888 +
.proj-tabs a { display: inline-flex; align-items: center; gap: 6px; padding: 4px 8px; color: var(--mute); font-size: 14px; }
889 +
.proj-tabs a:hover { color: var(--ink); text-decoration: none; }
890 +
.proj-tabs a.on { color: var(--ink); font-weight: 600; }
891 +
.proj-rows { list-style: none; margin: 0; padding: 0; }
892 +
.proj-rows li { display: flex; align-items: flex-start; gap: 12px; padding: 12px 16px; border-bottom: 1px solid var(--line); }
893 +
.proj-rows li:last-child { border-bottom: 0; }
894 +
.proj-rows li:hover { background: var(--soft); }
895 +
.proj-icon { flex: none; margin-top: 3px; color: var(--mute); }
896 +
.proj-main { flex: 1; min-width: 0; }
897 +
.proj-main p { margin: 2px 0 0; }
898 +
.proj-title { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; }
899 +
.proj-title .name { font-size: 16px; font-weight: 600; }
900 +
.proj-title .vis { margin-left: 2px; }
901 +
.proj-desc { color: var(--ink); font-size: 14px; }
902 +
.proj-list .empty { border: 0; margin: 0; }