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 | + | } |