OXYGENHUB

Repository and settings pages: centred browser, clone on top, section lists 64caed18236a

The repository page is no longer full width: the file browser sits in
the centred column with the sidebar beside it, the README spans the
same width as the file table, and the clone command moves from the
sidebar to the top bar above the files.

Repository, organisation and account settings list their sections on
the left (sticky, marking the one in view) and link to each; every
section box on the right is the same width.
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-09-28 10:37 UTC
Signature
✔ signed by Andy McCall
Hash
64caed18236aa8e4c69436394351041c78d2c4c01772a061ae74562a2605c507
Depends on
19bb31ff1f5c 1ac84c62ba53 3f4b0ea6eabf 4edcf24025cf 9df855f29948 b64e2537d702 c40c2d3edb51 d23464c0ac60 de505df5cf8d
Depended on by
1062a0f8e985 84e3c09656b2 c7067c7bda56 0ee5c3fb32eb d7d182467382 c9aacc0e7dc1 7edaff01d506 8ddebaa7e856 1f78655a0466 d0fbea2eb46e 2a83f5303458 e71846aa07b9 9008bf6c2b8c 83d5052bf76c 8fb35df7d66e a160b98fb922 34beb1a7e376 6ae41081ccd3
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
25 atoms, 0 tree operations, 6 files

Browse the tree at this change

modified assets/hub.js +16 −0
…
120 unchanged lines
121 121
    apply(cookieMode);
122 122
  }
123 123
124 +
  /* Settings pages: mark the section in view in the list of sections. */
125 +
  var navLinks = document.querySelectorAll(".section-nav a[href^='#']");
126 +
  if (navLinks.length && "IntersectionObserver" in window) {
127 +
    var byId = {};
128 +
    navLinks.forEach(function (a) { byId[a.getAttribute("href").slice(1)] = a; });
129 +
    var io = new IntersectionObserver(function (entries) {
130 +
      entries.forEach(function (e) {
131 +
        if (e.isIntersecting && byId[e.target.id]) {
132 +
          navLinks.forEach(function (a) { a.classList.remove("on"); });
133 +
          byId[e.target.id].classList.add("on");
134 +
        }
135 +
      });
136 +
    }, { rootMargin: "0px 0px -70% 0px" });
137 +
    Object.keys(byId).forEach(function (id) { var el = document.getElementById(id); if (el) io.observe(el); });
138 +
  }
139 +
124 140
  /* Copy buttons. */
125 141
  document.querySelectorAll("[data-copy]").forEach(function (b) {
126 142
    b.addEventListener("click", function () {
…
63 unchanged lines
modified templates/org_settings.html +12 −3
…
1 unchanged lines
2 2
{% block title %}{{ org.name }} settings{% endblock %}
3 3
{% block content %}
4 4
<div class="head"><h1>{{ org.name }} <small>organisation settings · <a href="/{{ org.name }}">repositories</a></small></h1></div>
5 +
<div class="sectioned">
6 +
<nav class="section-nav" aria-label="Sections">
7 +
  <a href="#general">General</a>
8 +
  <a href="#members">Members</a>
9 +
  <a href="#teams">Teams</a>
10 +
</nav>
11 +
<div class="sections">
5 12
{% if let Some(n) = notice %}<p class="note">{{ n }}</p>{% endif %}
6 13
{% if let Some(e) = error %}<p class="error">{{ e }}</p>{% endif %}
7 -
<form class="card" method="post" action="/orgs/{{ org.name }}/settings">
14 +
<form class="card" id="general" method="post" action="/orgs/{{ org.name }}/settings">
8 15
  <input type="hidden" name="_csrf" value="{{ shell.csrf }}">
9 16
  <label for="display_name">Display name</label><input type="text" id="display_name" name="display_name" value="{{ org.display_name }}">
10 17
  <label for="description">Description</label><input type="text" id="description" name="description" value="{{ org.description }}">
…
1 unchanged lines
12 19
  <select id="default_role" name="default_role">{% for r in ["none", "read", "triage", "write", "maintain", "admin"] %}<option value="{{ r }}"{% if org.default_role.as_str() == *r %} selected{% endif %}>{{ r }}</option>{% endfor %}</select>
13 20
  <button type="submit">SAVE</button>
14 21
</form>
15 -
<section class="panel">
22 +
<section class="panel" id="members">
16 23
  <h2>Members</h2>
17 24
  <table class="small">{% for (a, r) in members %}<tr><td><a href="/{{ a.username }}">{{ a.username }}</a></td><td>{{ r }}</td>
18 25
  <td><form class="tiny" method="post" action="/orgs/{{ org.name }}/settings/members"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><input type="hidden" name="username" value="{{ a.username }}"><button name="remove" value="1">remove</button></form></td></tr>{% endfor %}</table>
…
4 unchanged lines
23 30
    <button type="submit">ADD OR CHANGE</button>
24 31
  </form>
25 32
</section>
26 -
<section class="panel">
33 +
<section class="panel" id="teams">
27 34
  <h2>Teams</h2>
28 35
  {% for (t, ms) in teams %}
29 36
  <p><strong>{{ t.name }}</strong>: {% for a in ms %}{{ a.username }}{% if !loop.last %}, {% endif %}{% endfor %}{% if ms.is_empty() %}<span class="mute">nobody yet</span>{% endif %}
…
6 unchanged lines
36 43
    <div class="choice"><button type="submit" name="action" value="create">CREATE TEAM</button> <button type="submit" name="action" value="add">ADD MEMBER</button> <button type="submit" name="action" value="remove">REMOVE MEMBER</button></div>
37 44
  </form>
38 45
</section>
46 +
</div>
47 +
</div>
39 48
{% endblock %}
modified templates/overview.html +2 −4
…
2 unchanged lines
3 3
{% block title %}{{ repo.owner }}/{{ repo.name }}{% endblock %}
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 -
{% block mainclass %} class="wide"{% endblock %}
7 6
{% block content %}
8 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 %}
9 8
{% if o.conflicted %}<div class="strip bad">This state carries conflicts. <a href="{{ repo.url }}/conflicts">See them</a></div>{% endif %}
…
4 unchanged lines
14 13
      <a class="go small" href="{{ repo.url }}/changes/{{ repo.default_bookmark }}">{{ o.changes|plural("change", "changes") }}</a>
15 14
      {% if repo.can(crate::auth::Access::Write) %}<a class="go small" href="{{ repo.url }}/newfile/{{ repo.default_bookmark }}">new file</a>{% endif %}
16 15
      <form class="find inline-find" method="get" action="{{ repo.url }}/search"><input type="search" name="q" placeholder="Search code…"></form>
17 -
      {% if let Some(c) = o.latest %}<span class="small mute">latest <a href="{{ repo.url }}/change/{{ c.hash }}">{{ c.title }}</a> · {{ c.when_ms|ago }}</span>{% endif %}
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>
18 17
    </div>
18 +
    {% 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 19
    {% if let Some(q) = quickstart %}
20 20
    {% include "quickstart.html" %}
21 21
    {% else %}
…
16 unchanged lines
38 38
    {% if fork.forks > 0 %}<a class="small" href="{{ repo.url }}/forks">{{ fork.forks|plural("fork", "forks") }}</a>{% endif %}
39 39
    </div>
40 40
    {% 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 %}
41 -
    <h3>Clone</h3>
42 -
    <div class="clone"><code>oxy clone {{ repo.clone_url }}</code> <button class="small" data-copy="oxy clone {{ repo.clone_url }}">copy</button></div>
43 41
    <h3>Bookmarks</h3>
44 42
    <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>
45 43
    {% 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_settings.html +20 −7
…
6 unchanged lines
7 7
<div class="head"><h1>Settings <small>{{ repo.owner }}/{{ repo.name }}</small></h1></div>
8 8
{% if let Some(n) = notice %}<p class="note">{{ n }}</p>{% endif %}
9 9
{% if let Some(e) = error %}<p class="error">{{ e }}</p>{% endif %}
10 -
<form class="card wide" method="post" action="{{ repo.url }}/settings">
10 +
<div class="sectioned">
11 +
<nav class="section-nav" aria-label="Sections">
12 +
  <a href="#general">General</a>
13 +
  <a href="#landing">Landing requirements</a>
14 +
  <a href="#git-mirror">Git mirror</a>
15 +
  <a href="#checks">Required checks</a>
16 +
  {% if role == crate::auth::Role::Admin %}<a href="#webhooks">Webhooks</a>
17 +
  <a href="#collaborators">Collaborators</a>
18 +
  <a href="#danger">Delete</a>{% endif %}
19 +
</nav>
20 +
<div class="sections">
21 +
<form class="card wide" id="general" method="post" action="{{ repo.url }}/settings">
11 22
  <input type="hidden" name="_csrf" value="{{ shell.csrf }}">
12 23
  <h2>General</h2>
13 24
  <label for="description">Description</label><input type="text" id="description" name="description" value="{{ row.description }}">
…
8 unchanged lines
22 33
  </div>
23 34
  <button type="submit">SAVE</button>
24 35
</form>
25 -
<form class="card wide" method="post" action="{{ repo.url }}/settings/requirements">
36 +
<form class="card wide" id="landing" method="post" action="{{ repo.url }}/settings/requirements">
26 37
  <input type="hidden" name="_csrf" value="{{ shell.csrf }}">
27 38
  <h2>Landing requirements</h2>
28 39
  <label for="required_approvals">Sealed approvals required</label><input type="number" id="required_approvals" name="required_approvals" min="0" value="{{ requirements.required_approvals }}">
…
3 unchanged lines
32 43
  </div>
33 44
  <button type="submit">SAVE</button>
34 45
</form>
35 -
<form class="card wide" method="post" action="{{ repo.url }}/settings/mirror">
46 +
<form class="card wide" id="git-mirror" method="post" action="{{ repo.url }}/settings/mirror">
36 47
  <input type="hidden" name="_csrf" value="{{ shell.csrf }}">
37 48
  <h2>Git mirror</h2>
38 49
  <div class="choice"><label><input type="checkbox" name="enabled" value="1"{% if row.git_mirror %} checked{% endif %}> keep a read-only Git mirror of <strong>{{ row.default_bookmark }}</strong></label></div>
39 50
  <p class="small mute">For tools that only speak Git (CI runners, IDEs): <code>git clone {{ shell.base_url }}{{ repo.url }}.git</code>. Private repositories take a personal access token as the password. Pushing to the mirror is refused.{% if row.git_mirror && !row.git_mirror_state.is_empty() %} Last exported state <code>{{ row.git_mirror_state|short }}</code>.{% endif %}</p>
40 51
  <button type="submit">SAVE</button>
41 52
</form>
42 -
<form class="card wide" method="post" action="{{ repo.url }}/settings/checks">
53 +
<form class="card wide" id="checks" method="post" action="{{ repo.url }}/settings/checks">
43 54
  <input type="hidden" name="_csrf" value="{{ shell.csrf }}">
44 55
  <h2>Required checks</h2>
45 56
  <label for="required_checks">Check names that must pass on a proposal's projected state (comma-separated)</label><input type="text" id="required_checks" name="required_checks" value="{{ required_checks }}" placeholder="build, test">
…
1 unchanged lines
47 58
  <button type="submit">SAVE</button>
48 59
</form>
49 60
{% if role == crate::auth::Role::Admin %}
50 -
<section class="panel">
61 +
<section class="panel" id="webhooks">
51 62
  <h2>Webhooks</h2>
52 63
  {% if hooks.is_empty() %}<p class="mute small">None.</p>{% else %}
53 64
  <table class="small"><tr><th>URL</th><th>Events</th><th>Active</th><th></th></tr>
…
9 unchanged lines
63 74
    <button type="submit">ADD WEBHOOK</button>
64 75
  </form>
65 76
</section>
66 -
<section class="panel">
77 +
<section class="panel" id="collaborators">
67 78
  <h2>Collaborators</h2>
68 79
  {% if collaborators.is_empty() %}<p class="mute small">Nobody beyond the owner{% if !teams.is_empty() %} and the organisation's members{% endif %}.</p>{% else %}
69 80
  <table class="small">
…
12 unchanged lines
82 93
    <button type="submit">ADD</button>
83 94
  </form>
84 95
</section>
85 -
<details class="danger">
96 +
<details class="danger" id="danger">
86 97
  <summary>Delete this repository</summary>
87 98
  <form class="card" method="post" action="{{ repo.url }}/settings/delete">
88 99
    <input type="hidden" name="_csrf" value="{{ shell.csrf }}">
…
3 unchanged lines
92 103
  </form>
93 104
</details>
94 105
{% endif %}
106 +
</div>
107 +
</div>
95 108
{% endblock %}
modified templates/settings.html +12 −3
…
5 unchanged lines
6 6
<div class="head"><h1>Profile <small>{{ me.username }}</small></h1></div>
7 7
{% if let Some(n) = notice %}<p class="note">{{ n }}</p>{% endif %}
8 8
{% if let Some(e) = error %}<p class="error">{{ e }}</p>{% endif %}
9 -
<form class="card" method="post" action="/settings">
9 +
<div class="sectioned">
10 +
<nav class="section-nav" aria-label="Sections">
11 +
  <a href="#profile">Profile</a>
12 +
  <a href="#appearance">Appearance</a>
13 +
  <a href="#password">Password</a>
14 +
</nav>
15 +
<div class="sections">
16 +
<form class="card" id="profile" method="post" action="/settings">
10 17
  <input type="hidden" name="_csrf" value="{{ shell.csrf }}">
11 18
  <label for="display_name">Display name</label><input type="text" id="display_name" name="display_name" value="{{ me.display_name }}">
12 19
  <label for="email">Email</label><input type="email" id="email" name="email" value="{{ me.email }}">
…
4 unchanged lines
17 24
  <p class="small mute">Shown on <a href="/{{ me.username }}">your profile</a>, with a picture drawn from your username.</p>
18 25
  <button type="submit">SAVE</button>
19 26
</form>
20 -
<form class="card" method="post" action="/settings/appearance">
27 +
<form class="card" id="appearance" method="post" action="/settings/appearance">
21 28
  <input type="hidden" name="_csrf" value="{{ shell.csrf }}">
22 29
  <h2>Appearance</h2>
23 30
  <fieldset class="choices">
…
4 unchanged lines
28 35
  </fieldset>
29 36
  <button type="submit">SAVE</button>
30 37
</form>
31 -
<form class="card" method="post" action="/settings/password">
38 +
<form class="card" id="password" method="post" action="/settings/password">
32 39
  <input type="hidden" name="_csrf" value="{{ shell.csrf }}">
33 40
  <h2>Change password</h2>
34 41
  <label for="current">Current password</label><input type="password" id="current" name="current" autocomplete="current-password" required>
…
1 unchanged lines
36 43
  <p class="small mute">Changing it signs out every other session.</p>
37 44
  <button type="submit">CHANGE PASSWORD</button>
38 45
</form>
46 +
</div>
47 +
</div>
39 48
{% endblock %}
modified templates/style.css +20 −0
…
363 unchanged lines
364 364
.counts-line span { margin-right: 2px; }
365 365
.count-chip { display: inline-block; min-width: 1.6em; padding: 0 6px; margin-left: 4px; border-radius: 9px; background: var(--soft); color: var(--mute); font-size: 11px; text-align: center; }
366 366
.pager { display: flex; gap: 12px; align-items: center; justify-content: center; margin: 14px 0; }
367 +
368 +
/* repository page: the clone command sits at the right of the top bar, the README spans the column */
369 +
.top-clone { margin-left: auto; flex: 0 1 380px; min-width: 200px; }
370 +
.top-clone code { font-size: 12px; overflow: hidden; text-overflow: ellipsis; }
371 +
.latest-line { margin: -4px 0 8px; }
372 +
article.doc.readme { max-width: none; }
373 +
374 +
/* settings pages: section headings on the left, every section the same width on the right */
375 +
.sectioned { display: grid; grid-template-columns: 190px minmax(0, 1fr); gap: 28px; align-items: start; }
376 +
.section-nav { position: sticky; top: 14px; display: flex; flex-direction: column; gap: 2px; }
377 +
.section-nav a { padding: 6px 12px; border-left: 2px solid var(--line); color: var(--mute); font-size: 14px; }
378 +
.section-nav a:hover, .section-nav a.on { border-left-color: var(--acc); color: var(--ink); text-decoration: none; }
379 +
.sections > form.card, .sections > form.card.wide, .sections > .panel, .sections > details { max-width: none; width: 100%; box-sizing: border-box; scroll-margin-top: 14px; }
380 +
.sections .panel form.card { max-width: 720px; }
381 +
.sections > details > form.card { max-width: none; }
382 +
@media (max-width: 760px) {
383 +
  .sectioned { grid-template-columns: 1fr; gap: 12px; }
384 +
  .section-nav { position: static; flex-direction: row; flex-wrap: wrap; }
385 +
  .section-nav a { border-left: 0; border-bottom: 2px solid var(--line); }
386 +
}