OXYGENHUB

UI polish: keys, backups, wiki, editor, bookmarks, summaries, backdrops ffb67a81188c

- The top bar's + new is the primary blue (#187).
- Settings › Keys is cards of one width with a gap, like Tokens (#188);
  Backups' Where and how often is as wide as its Runs (#189).
- Proposals offer Summarise with <assistant>: told that it works with
  Oxygen and that a proposal is a merge or pull request, with the site,
  organisation and repository prompts after, it posts its summary as a
  comment via the assistant (migration 0066) (#190).
- The overview takes ?at=<bookmark or tag> with a picker beside the
  search; a ref's tree root opens it there, side bar and all (#191).
- Wiki pages have a title row with who edited them and the revision
  count, the page as a card, and Pages, Contents and a clone box with a
  copy button in the side bar; headings get ids (#192, #194).
- The Markdown editor is laid out like GitLab's: a Preview tab, icon
  buttons (strikethrough, task list and table added), fullscreen, and a
  Markdown supported footer (#193).
- Conflicts is the usual width (#195).
- Backdrops on these pages: a repository's own sky on its overview,
  wiki, proposals and Conflicts; the mark on Keys, Tokens and Backups. A
  lone cluster sits in the wider margin (#196).

Closes #187, #188, #189, #190, #191, #192, #193, #194, #195, #196
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-10-14 14:09 UTC
Signature
✔ signed by Andy McCall
Hash
ffb67a81188c4cdc0abc3697121000cef9d31950af99495923285770c7d602c0
Depends on
00ef7b83b340 145306044896 17ca6cddc534 18e381ea88c2 19bb31ff1f5c 206c1fc1b06a 2f14c5f39e81 3ea0b8af2d46 3f4b0ea6eabf 4edcf24025cf 4f37afcc3583 61ecbd299f22 66fd9d6b1452 67b24adbf35a 6907a989e82a 6e7dfa0a42b9 757195ad7a5d 7d2eb590051f 7f283f8e8b97 83d5052bf76c 8e96499f2705 9ff27da517fe a1b9869565aa c79a1111dd56 c9aacc0e7dc1 d23464c0ac60 d7d182467382 da8f7826a645 de505df5cf8d e71846aa07b9 eb56c8bc4d0e ed89fae5180b f677d6270298 fd958fbc06fb
Depended on by
351cf57e5608 84e3c09656b2 b349e15a3eab 166f6e9bf4f1 33d98147511b a10446c68043 8a48dd830178 a7aa76ac52d7 119fd17d213f c08cec372c81 5d90e0e045e2 b5a0a69b3622 5360cea36c30 addec2bd6423
In bookmarks
api-unknown-fields cancel-landed-runs diffusion-pin-bookmark draft-ready main oh-comment-body-file proposal-list-fast
In tags
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
92 atoms, 2 tree operations, 27 files

Browse the tree at this change

added crates/hub/migrations/0066_proposal_comment_via.sql +3 −0
1 +
-- A proposal comment an AI assistant wrote for the person who asked it
2 +
-- (oxygenhub#190): its name, empty for a person's own comment.
3 +
ALTER TABLE proposal_comments ADD COLUMN via TEXT NOT NULL DEFAULT '';
added templates/repo_backdrop.html +4 −0
1 +
{# A repository page's backdrop (oxygenhub#196): its own sky, when the
2 +
   handler gave it one. The page marks its content with data-backdrop-avoid. #}
3 +
{% if !repo.backdrop.is_empty() %}<script type="application/json" id="backdrop-data">{{ repo.backdrop|safe }}</script>
4 +
{% include "backdrop.html" %}{% endif %}
replaced assets/backdrop/backdrop_bg.wasm +0 −0

No line changes.

modified assets/hub.js +44 −5
…
18 unchanged lines
19 19
  }
20 20
  if (document.querySelector("textarea.code")) load("/static/vendor/editor.js");
21 21
  var mds = document.querySelectorAll("textarea.md");
22 +
  /* The Markdown editor in GitLab's manner (oxygenhub#193): a Preview tab,
23 +
     then icon buttons, fullscreen at the far end, and a footer that says
24 +
     Markdown is what it takes. Still a textarea underneath. */
22 25
  if (mds.length) load("/static/vendor/easymde.min.js", "/static/vendor/easymde.min.css").then(function () {
23 26
    var E = window.EasyMDE;
24 -
    function b(name, action, text, title) { return { name: name, action: action, text: text, title: title, className: "mde-btn" }; }
25 -
    var toolbar = [b("bold", E.toggleBold, "B", "Bold"), b("italic", E.toggleItalic, "I", "Italic"), b("heading", E.toggleHeadingSmaller, "H", "Heading"), "|",
26 -
      b("code", E.toggleCodeBlock, "</>", "Code"), b("quote", E.toggleBlockquote, "“", "Quote"), b("ul", E.toggleUnorderedList, "•", "List"), b("ol", E.toggleOrderedList, "1.", "Numbered list"), "|",
27 -
      b("link", E.drawLink, "link", "Link"), "|", b("preview", E.togglePreview, "preview", "Preview")];
28 -
    mds.forEach(function (t) { t._mde = new E({ element: t, spellChecker: false, status: false, forceSync: true, autoDownloadFontAwesome: false, toolbar: toolbar }); });
27 +
    function svg(d) { return '<svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">' + d + '</svg>'; }
28 +
    var I = {
29 +
      bold: svg('<path d="M4.5 2.5h4.25a2.75 2.75 0 0 1 0 5.5H4.5zM4.5 8h5a2.75 2.75 0 0 1 0 5.5h-5z"/>'),
30 +
      italic: svg('<path d="M6.5 2.5h6M3.5 13.5h6M10 2.5l-4 11"/>'),
31 +
      strike: svg('<path d="M2.5 8h11M11 4.5c-.5-1.2-1.7-2-3.2-2-1.9 0-3.3 1-3.3 2.6 0 .9.4 1.5 1.2 2M5 11.5c.5 1.2 1.8 2 3.3 2 2 0 3.4-1.1 3.4-2.7"/>'),
32 +
      heading: svg('<path d="M3.5 2.5v11M12.5 2.5v11M3.5 8h9"/>'),
33 +
      quote: svg('<path d="M2.5 3.5h11M5.5 8h8M5.5 12.5h8M2.5 7v6"/>'),
34 +
      code: svg('<path d="M5.5 4.5 2 8l3.5 3.5M10.5 4.5 14 8l-3.5 3.5"/>'),
35 +
      link: svg('<path d="M7 9a3 3 0 0 0 4.2.3l2-2a3 3 0 0 0-4.2-4.2l-1 1M9 7a3 3 0 0 0-4.2-.3l-2 2a3 3 0 0 0 4.2 4.2l1-1"/>'),
36 +
      ul: svg('<path d="M6 4h7.5M6 8h7.5M6 12h7.5"/><circle cx="2.75" cy="4" r=".75" fill="currentColor"/><circle cx="2.75" cy="8" r=".75" fill="currentColor"/><circle cx="2.75" cy="12" r=".75" fill="currentColor"/>'),
37 +
      ol: svg('<path d="M6.5 4h7M6.5 8h7M6.5 12h7M2 2.5h1v3M2 7.5h1.5L2 9.5h1.5M2 10.5h1.5v3H2M2 12h1.5"/>'),
38 +
      task: svg('<rect x="2" y="2.5" width="4" height="4" rx=".75"/><path d="m2.75 11 1 1 1.75-2M8.5 4.5h5M8.5 11.5h5"/>'),
39 +
      table: svg('<rect x="2" y="2.5" width="12" height="11" rx="1"/><path d="M2 6.5h12M2 10h12M6.5 6.5v7"/>'),
40 +
      full: svg('<path d="M2.5 6V2.5H6M10 2.5h3.5V6M13.5 10v3.5H10M6 13.5H2.5V10"/>')
41 +
    };
42 +
    function b(name, action, title, icon, text) { return { name: name, action: action, title: title, icon: icon ? '<span class="mde-ico">' + icon + '</span>' : undefined, text: text, className: "mde-btn" + (text ? " mde-text" : "") }; }
43 +
    function task(ed) {
44 +
      var cm = ed.codemirror, from = cm.getCursor("from"), to = cm.getCursor("to");
45 +
      for (var l = from.line; l <= to.line; l++) {
46 +
        var line = cm.getLine(l), m = line.match(/^(\s*)[-*] \[[ xX]\] /);
47 +
        if (m) cm.replaceRange(m[1], { line: l, ch: 0 }, { line: l, ch: m[0].length });
48 +
        else cm.replaceRange("- [ ] ", { line: l, ch: (line.match(/^\s*/) || [""])[0].length });
49 +
      }
50 +
      cm.focus();
51 +
    }
52 +
    var toolbar = [b("preview", E.togglePreview, "Preview", null, "Preview"), "|",
53 +
      b("bold", E.toggleBold, "Bold (Ctrl+B)", I.bold), b("italic", E.toggleItalic, "Italic (Ctrl+I)", I.italic), b("strikethrough", E.toggleStrikethrough, "Strikethrough", I.strike), b("heading", E.toggleHeadingSmaller, "Heading", I.heading), "|",
54 +
      b("quote", E.toggleBlockquote, "Quote", I.quote), b("code", E.toggleCodeBlock, "Code", I.code), b("link", E.drawLink, "Link (Ctrl+K)", I.link), "|",
55 +
      b("unordered-list", E.toggleUnorderedList, "Bullet list", I.ul), b("ordered-list", E.toggleOrderedList, "Numbered list", I.ol), b("task-list", task, "Task list", I.task), b("table", E.drawTable, "Table", I.table), "|",
56 +
      b("fullscreen", E.toggleFullScreen, "Fullscreen", I.full)];
57 +
    mds.forEach(function (t) {
58 +
      t._mde = new E({ element: t, spellChecker: false, status: false, forceSync: true, autoDownloadFontAwesome: false, toolbar: toolbar,
59 +
        placeholder: t.getAttribute("placeholder") || "Write in Markdown…", minHeight: "120px", sideBySideFullscreen: false });
60 +
      var c = t._mde.element.parentNode.querySelector(".EasyMDEContainer") || t.nextElementSibling;
61 +
      if (c && c.classList.contains("EasyMDEContainer")) {
62 +
        var f = document.createElement("div");
63 +
        f.className = "mde-foot small mute";
64 +
        f.innerHTML = '<span>Markdown supported</span><span class="mde-md" title="Markdown" aria-hidden="true">M↓</span>';
65 +
        c.appendChild(f);
66 +
      }
67 +
    });
29 68
    /* An editor shown later (a :target edit form) lays itself out again. */
30 69
    window.addEventListener("hashchange", function () { mds.forEach(function (t) { if (t._mde) setTimeout(function () { t._mde.codemirror.refresh(); }, 0); }); });
31 70
  });
…
642 unchanged lines
modified crates/backdrop/src/sky.rs +23 −0
…
308 unchanged lines
309 309
            let (l, r) = (x0.max(0.0), (self.w - x1).max(0.0));
310 310
            (l >= MIN_MARGIN && r >= MIN_MARGIN).then_some((l, r, x1))
311 311
        });
312 +
        // One repository's own sky (oxygenhub#196): its cluster sits in the
313 +
        // wider margin, a little below the middle, even a narrow one, so
314 +
        // it never lands on the page's heading or tabs.
315 +
        if n == 1 {
316 +
            if let Some((x0, x1)) = self.avoid {
317 +
                let (l, r) = (x0.max(0.0), (self.w - x1).max(0.0));
318 +
                if l.max(r) >= 40.0 && self.w > 0.0 {
319 +
                    let cx = if r >= l { x1 + r / 2.0 } else { l / 2.0 };
320 +
                    self.clusters[0].fx = cx / self.w;
321 +
                    self.clusters[0].fy = 0.6;
322 +
                    return;
323 +
                }
324 +
            }
325 +
        }
312 326
        match margins {
313 327
            Some((l, r, x1)) => {
314 328
                let left = (((n as f32) * l / (l + r)).round() as usize).min(n);
…
373 unchanged lines
688 702
            octants[((m.angle / TAU * 8.0) as usize).min(7)] += 1;
689 703
        }
690 704
        assert!(octants.iter().all(|&n| (12..=40).contains(&n)), "{octants:?}");
705 +
    }
706 +
707 +
    #[test]
708 +
    fn one_repository_sits_in_the_wider_margin() {
709 +
        let repos = vec![Repo { name: "andy/demo".into() }];
710 +
        let mut s = Sky::new(&repos, &[], 1400.0, 900.0, false);
711 +
        s.resize(1400.0, 900.0, Some((127.0, 1259.0)));
712 +
        let (x, y) = s.core(0);
713 +
        assert!((x - (1259.0 + 141.0 / 2.0)).abs() < 8.0 && (y - 540.0).abs() < 8.0, "{x} {y}");
691 714
    }
692 715
693 716
    #[test]
…
36 unchanged lines
modified crates/hub/src/assistants.rs +90 −13
…
250 unchanged lines
251 251
    let like = format!("%{old}%");
252 252
    match scope {
253 253
        Scope::Site => {
254 -
            sqlx::query("UPDATE proposal_reviews SET via = ? WHERE via = ?").bind(new).bind(old).execute(pool).await?;
254 +
            for table in ["proposal_reviews", "proposal_comments"] {
255 +
                sqlx::query(&format!("UPDATE {table} SET via = ? WHERE via = ?")).bind(new).bind(old).execute(pool).await?;
256 +
            }
255 257
            sqlx::query("UPDATE proposal_events SET event = replace(event, ?, ?) WHERE event LIKE ?")
256 258
                .bind(old)
257 259
                .bind(new)
…
3 unchanged lines
261 263
        }
262 264
        Scope::Org(_) => {
263 265
            let in_org = "proposal_id IN (SELECT p.id FROM proposals p JOIN repositories r ON r.id = p.repo_id WHERE r.owner = ?)";
264 -
            sqlx::query(&format!("UPDATE proposal_reviews SET via = ? WHERE via = ? AND {in_org}"))
265 -
                .bind(new)
266 -
                .bind(old)
267 -
                .bind(owner_hint)
268 -
                .execute(pool)
269 -
                .await?;
266 +
            for table in ["proposal_reviews", "proposal_comments"] {
267 +
                sqlx::query(&format!("UPDATE {table} SET via = ? WHERE via = ? AND {in_org}"))
268 +
                    .bind(new)
269 +
                    .bind(old)
270 +
                    .bind(owner_hint)
271 +
                    .execute(pool)
272 +
                    .await?;
273 +
            }
270 274
            sqlx::query(&format!("UPDATE proposal_events SET event = replace(event, ?, ?) WHERE event LIKE ? AND {in_org}"))
271 275
                .bind(old)
272 276
                .bind(new)
…
4 unchanged lines
277 281
        }
278 282
        Scope::Repo(id) => {
279 283
            let in_repo = "proposal_id IN (SELECT id FROM proposals WHERE repo_id = ?)";
280 -
            sqlx::query(&format!("UPDATE proposal_reviews SET via = ? WHERE via = ? AND {in_repo}"))
281 -
                .bind(new)
282 -
                .bind(old)
283 -
                .bind(id)
284 -
                .execute(pool)
285 -
                .await?;
284 +
            for table in ["proposal_reviews", "proposal_comments"] {
285 +
                sqlx::query(&format!("UPDATE {table} SET via = ? WHERE via = ? AND {in_repo}"))
286 +
                    .bind(new)
287 +
                    .bind(old)
288 +
                    .bind(id)
289 +
                    .execute(pool)
290 +
                    .await?;
291 +
            }
286 292
            sqlx::query(&format!("UPDATE proposal_events SET event = replace(event, ?, ?) WHERE event LIKE ? AND {in_repo}"))
287 293
                .bind(old)
288 294
                .bind(new)
…
523 unchanged lines
812 818
    let budget = usize::try_from(a.budget).unwrap_or(DEFAULT_BUDGET as usize).max(1_000);
813 819
    let prompts = prompt_for_repo(&app.db, row).await.map_err(|e| e.to_string())?;
814 820
    let system = review_system_prompt(a, &app.config.site.name, &prompts);
821 +
    let user = review_context(row, p, &loaded, budget.saturating_sub(system.chars().count()));
822 +
    ask(app, a, &system, &user).await
823 +
}
824 +
825 +
// -------------------------------------------------------- summaries
826 +
827 +
/// The system prompt for a summary posted as a comment (oxygenhub#190).
828 +
pub fn summary_system_prompt(a: &Assistant, site: &str, prompts: &str) -> String {
829 +
    let mut s = format!(
830 +
        "You are {name}, an AI assistant working on {site}, an OxygenHub site, with a next-generation version control system \
831 +
         called Oxygen (its command is oxy). A proposal is Oxygen's equivalent of a merge request or pull request. \
832 +
         Please summarise the proposal and comment on it. A person asked for this; it is posted as a comment made via you, \
833 +
         under their name. Answer in Markdown: a short summary of what the proposal changes and why, then anything a reviewer \
834 +
         should look at closely; keep to what the description and the diff show.",
835 +
        name = a.name
836 +
    );
837 +
    if !prompts.trim().is_empty() {
838 +
        s.push_str("\n\n");
839 +
        s.push_str(prompts.trim());
840 +
    }
841 +
    s
842 +
}
843 +
844 +
/// Ask `a` to summarise proposal `p` for `by`: like [`request_review`],
845 +
/// but the answer is a conversation comment via the assistant.
846 +
pub async fn request_summary(
847 +
    app: &Shared,
848 +
    row: &crate::db::repos::RepoRow,
849 +
    p: &crate::db::proposals::ProposalRow,
850 +
    by: &crate::db::accounts::Account,
851 +
    a: Assistant,
852 +
) -> Result<(), String> {
853 +
    app.assistant_gate.lock().unwrap_or_else(|e| e.into_inner()).take(row.id, p.id, &a.name)?;
854 +
    let pool = &app.db;
855 +
    crate::db::proposals::add_event(pool, p.id, &by.username, &format!("asked {} for a summary", a.name), "").await.ok();
856 +
    crate::db::accounts::audit(
857 +
        pool,
858 +
        Some(by),
859 +
        "assistant.asked",
860 +
        &format!("{}/{}", row.owner, row.name),
861 +
        &format!("{} #{} summary", a.name, p.number),
862 +
    )
863 +
    .await
864 +
    .ok();
865 +
    let (app, row, p, by) = (app.clone(), row.clone(), p.clone(), by.clone());
866 +
    tokio::spawn(async move {
867 +
        let outcome = answer_summary(&app, &row, &p, &a).await;
868 +
        app.assistant_gate.lock().unwrap_or_else(|e| e.into_inner()).release(p.id, &a.name);
869 +
        match outcome {
870 +
            Ok(body) => {
871 +
                crate::db::proposals::add_comment_via(&app.db, p.id, by.id, &by.username, &body, &a.name).await.ok();
872 +
            }
873 +
            Err(why) => {
874 +
                crate::db::proposals::add_event(&app.db, p.id, &by.username, &format!("{} did not answer", a.name), &why).await.ok();
875 +
            }
876 +
        }
877 +
    });
878 +
    Ok(())
879 +
}
880 +
881 +
async fn answer_summary(
882 +
    app: &Shared,
883 +
    row: &crate::db::repos::RepoRow,
884 +
    p: &crate::db::proposals::ProposalRow,
885 +
    a: &Assistant,
886 +
) -> Result<String, String> {
887 +
    let repo = crate::repos::names::RepoName::new(&row.owner, &row.name).ok_or("bad repository name")?;
888 +
    let loaded = crate::web::proposals::load(app, &repo, p.clone()).await.map_err(|e| format!("the proposal could not be loaded: {e}"))?;
889 +
    let budget = usize::try_from(a.budget).unwrap_or(DEFAULT_BUDGET as usize).max(1_000);
890 +
    let prompts = prompt_for_repo(&app.db, row).await.map_err(|e| e.to_string())?;
891 +
    let system = summary_system_prompt(a, &app.config.site.name, &prompts);
815 892
    let user = review_context(row, p, &loaded, budget.saturating_sub(system.chars().count()));
816 893
    ask(app, a, &system, &user).await
817 894
}
…
56 unchanged lines
modified crates/hub/src/db/proposals.rs +29 −0
…
61 unchanged lines
62 62
    pub anchor_index: i64,
63 63
    pub resolved: bool,
64 64
    pub created_ms: i64,
65 +
    /// The AI assistant that wrote it for the author (migration 0066),
66 +
    /// empty for the author's own comment.
67 +
    #[sqlx(default)]
68 +
    pub via: String,
65 69
}
66 70
67 71
#[derive(Clone, Debug, FromRow)]
…
178 unchanged lines
246 250
    .bind(anchor.map(|a| a.0).unwrap_or(""))
247 251
    .bind(anchor.map(|a| a.1).unwrap_or(0))
248 252
    .bind(now_ms())
253 +
    .fetch_one(pool)
254 +
    .await?;
255 +
    touch(pool, proposal_id).await?;
256 +
    Ok(row)
257 +
}
258 +
259 +
/// A conversation comment an AI assistant (`via`) wrote for `author`,
260 +
/// who asked it.
261 +
pub async fn add_comment_via(
262 +
    pool: &Pool,
263 +
    proposal_id: i64,
264 +
    author_id: i64,
265 +
    author: &str,
266 +
    body: &str,
267 +
    via: &str,
268 +
) -> sqlx::Result<CommentRow> {
269 +
    let row = sqlx::query_as(
270 +
        "INSERT INTO proposal_comments (proposal_id, author_id, author, body, path, anchor_change, anchor_index, created_ms, via) VALUES (?, ?, ?, ?, '', '', 0, ?, ?) RETURNING *",
271 +
    )
272 +
    .bind(proposal_id)
273 +
    .bind(author_id)
274 +
    .bind(author)
275 +
    .bind(body)
276 +
    .bind(now_ms())
277 +
    .bind(via)
249 278
    .fetch_one(pool)
250 279
    .await?;
251 280
    touch(pool, proposal_id).await?;
…
124 unchanged lines
modified crates/hub/src/repos/view.rs +5 −3
…
1156 unchanged lines
1157 1157
    }
1158 1158
}
1159 1159
1160 -
pub fn overview(repo: &Repo, cache: &StateCache, default_bookmark: &str) -> Result<Overview> {
1161 -
    let target = match resolve(repo, cache, default_bookmark) {
1160 +
/// The overview of `shown` (a bookmark or tag; the default bookmark
1161 +
/// unless the viewer chose another, oxygenhub#191).
1162 +
pub fn overview(repo: &Repo, cache: &StateCache, default_bookmark: &str, shown: &str) -> Result<Overview> {
1163 +
    let target = match resolve(repo, cache, shown) {
1162 1164
        Ok(t) => t,
1163 -
        Err(_) => RefTarget { label: default_bookmark.into(), kind: RefKind::Bookmark, heads: vec![] },
1165 +
        Err(_) => RefTarget { label: shown.into(), kind: RefKind::Bookmark, heads: vec![] },
1164 1166
    };
1165 1167
    let heads = target.heads.len();
1166 1168
    // One load of the default state serves the counts, the latest change
…
352 unchanged lines
modified crates/hub/src/web/home.rs +10 −0
…
85 unchanged lines
86 86
    serde_json::json!({ "now": now, "repos": slugs, "events": evs, "live": live }).to_string().replace('<', "\\u003c")
87 87
}
88 88
89 +
/// One repository's sky (#196): the backdrop of its own pages, from the
90 +
/// database only, with no live polling. Empty when it cannot be read.
91 +
pub(crate) async fn repo_sky(app: &Shared, row: &db::repos::RepoRow) -> String {
92 +
    let now = db::now_ms();
93 +
    match db::activity::sky(&app.db, &[row.id], now - SKY_DAYS * 86_400_000, SKY_EVENTS).await {
94 +
        Ok(events) => sky_json(std::slice::from_ref(row), &events, now, ""),
95 +
        Err(_) => String::new(),
96 +
    }
97 +
}
98 +
89 99
/// The repositories a sky draws: `mine`, the viewer's own (member) ones,
90 100
/// as the dashboard's; `hub`, the public ones the viewer may see, as
91 101
/// Explore's. Never a private repository in the hub's sky.
…
294 unchanged lines
modified crates/hub/src/web/mod.rs +11 −0
…
464 unchanged lines
465 465
    /// Whether it pins, or is pinned by, another repository here: the
466 466
    /// Diffusion tab (#150).
467 467
    pub diffusion: bool,
468 +
    /// The backdrop's data, a sky of this repository alone; empty for none
469 +
    /// (docs/backgrounds.md, oxygenhub#196).
470 +
    pub backdrop: String,
468 471
}
469 472
470 473
impl RepoShell {
…
17 unchanged lines
488 491
            tests: crate::junit::has_reports(app, row.id),
489 492
            reports: crate::reports::has_reports(app, row.id),
490 493
            diffusion: crate::diffusion::involved(app, row.id),
494 +
            backdrop: String::new(),
491 495
        }
492 496
    }
493 497
498 +
    /// Give the page a backdrop: this repository's sky, its activity of the
499 +
    /// last 30 days from the database (#196).
500 +
    pub async fn with_sky(mut self, app: &Shared, row: &db::repos::RepoRow) -> RepoShell {
501 +
        self.backdrop = home::repo_sky(app, row).await;
502 +
        self
503 +
    }
504 +
494 505
    pub fn with_role(mut self, role: Role) -> RepoShell {
495 506
        self.role = Some(role);
496 507
        self
…
273 unchanged lines
modified crates/hub/src/web/proposals.rs +16 −3
…
856 unchanged lines
857 857
        project_choices,
858 858
        projects,
859 859
        shell,
860 -
        repo: RepoShell::new(app, &row, "proposals").with_role(role),
860 +
        repo: RepoShell::new(app, &row, "proposals").with_role(role).with_sky(app, &row).await,
861 861
        body_html: super::issues::markdown(app, &row, &row.default_bookmark, &p.body).await,
862 862
        p,
863 863
        tab,
…
691 unchanged lines
1555 1555
    #[serde(rename = "_csrf")]
1556 1556
    csrf_token: String,
1557 1557
    assistant: String,
1558 +
    /// `summary` asks for a summary comment (oxygenhub#190); otherwise a review.
1559 +
    #[serde(default)]
1560 +
    task: String,
1558 1561
}
1559 1562
1560 1563
/// `POST /{owner}/{name}/proposal/{number}/assistant`: the author or a
…
29 unchanged lines
1590 1593
        return Ok(back(&owner, &name, number, "conversation", None, Some(format!("No assistant called {} here.", f.assistant.trim()))));
1591 1594
    };
1592 1595
    let who = a.name.clone();
1593 -
    match crate::assistants::request_review(&app, &row, &p, &me.account, a).await {
1596 +
    let summary = f.task == "summary";
1597 +
    let asked = if summary {
1598 +
        crate::assistants::request_summary(&app, &row, &p, &me.account, a).await
1599 +
    } else {
1600 +
        crate::assistants::request_review(&app, &row, &p, &me.account, a).await
1601 +
    };
1602 +
    match asked {
1594 1603
        Ok(()) => Ok(back(
1595 1604
            &owner,
1596 1605
            &name,
1597 1606
            number,
1598 1607
            "conversation",
1599 -
            Some(format!("Asked {who} to review; its answer appears here when it comes.")),
1608 +
            Some(if summary {
1609 +
                format!("Asked {who} for a summary; its comment appears here when it comes.")
1610 +
            } else {
1611 +
                format!("Asked {who} to review; its answer appears here when it comes.")
1612 +
            }),
1600 1613
            None,
1601 1614
        )),
1602 1615
        Err(e) => Ok(back(&owner, &name, number, "conversation", None, Some(format!("Not asked: {e}.")))),
…
147 unchanged lines
modified crates/hub/src/web/refs.rs +1 −1
…
171 unchanged lines
172 172
    let shell = Shell::new(&app, &format!("/{owner}/{name}/conflicts"), &viewer);
173 173
    let (repo, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
174 174
    let conflicts = app.repos.read(&repo, view::conflicts).await.map_err(|e| Error::from_repo(shell.clone(), e))?;
175 -
    Ok(Page(ConflictsPage { shell, repo: RepoShell::new(&app, &row, "conflicts").with_role(role), conflicts }))
175 +
    Ok(Page(ConflictsPage { shell, repo: RepoShell::new(&app, &row, "conflicts").with_role(role).with_sky(&app, &row).await, conflicts }))
176 176
}
177 177
178 178
#[derive(Template)]
…
37 unchanged lines
modified crates/hub/src/web/repo.rs +22 −9
…
31 unchanged lines
32 32
    used_by: Vec<crate::diffusion::Consumer>,
33 33
    /// The upstreams it pins, with its status there (#150).
34 34
    follows: Vec<(crate::db::repos::RepoRow, crate::diffusion::Consumer)>,
35 +
    /// The bookmark or tag shown: the default unless `?at=` chose another
36 +
    /// (#191).
37 +
    at: String,
35 38
}
36 39
40 +
#[derive(Deserialize)]
41 +
pub struct OverviewQuery {
42 +
    at: Option<String>,
43 +
}
44 +
37 45
/// A remote name from the hub's host: its first label when that is a
38 46
/// plain lowercase word (`earth` for `https://earth.example.org:7343`),
39 47
/// otherwise `hub`.
…
62 unchanged lines
102 110
    State(app): State<Shared>,
103 111
    viewer: Viewer,
104 112
    Path((owner, name)): Path<(String, String)>,
113 +
    Query(q): Query<OverviewQuery>,
105 114
) -> Result<Page<OverviewPage>, Error> {
106 115
    let shell = Shell::new(&app, &format!("/{owner}/{name}"), &viewer);
107 116
    let (repo, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
108 117
    let default = row.default_bookmark.clone();
109 -
    let mut o =
110 -
        app.repos.read(&repo, move |r, cache| view::overview(r, cache, &default)).await.map_err(|e| Error::from_repo(shell.clone(), e))?;
118 +
    let at = q.at.map(|a| a.trim().to_string()).filter(|a| !a.is_empty()).unwrap_or_else(|| default.clone());
119 +
    let shown = at.clone();
120 +
    let mut o = app
121 +
        .repos
122 +
        .read(&repo, move |r, cache| view::overview(r, cache, &default, &shown))
123 +
        .await
124 +
        .map_err(|e| Error::from_repo(shell.clone(), e))?;
111 125
    // Contributors link to the hub accounts their keys are linked to.
112 126
    let linked: HashMap<String, String> = crate::db::accounts::identity_accounts(&app.db).await.unwrap_or_default().into_iter().collect();
113 127
    let hub_key = app.hub_key.key.to_hex();
…
24 unchanged lines
138 152
    }
139 153
    merged.sort_by(|a, b| b.changes.cmp(&a.changes).then(a.name.cmp(&b.name)));
140 154
    o.contributors = merged;
141 -
    let rs = RepoShell::new(&app, &row, "code").with_role(role);
155 +
    let rs = RepoShell::new(&app, &row, "code").with_role(role).with_sky(&app, &row).await;
142 156
    let readme_html = o.readme_html(&rs, &o.tree.target.label);
143 157
    let watching = match viewer.account() {
144 158
        Some(a) => crate::db::notify::watching(&app.db, a.id, row.id).await.unwrap_or(false),
…
25 unchanged lines
170 184
        topics,
171 185
        used_by,
172 186
        follows,
187 +
        at,
173 188
    }))
174 189
}
175 190
…
15 unchanged lines
191 206
    readme_html: Option<String>,
192 207
}
193 208
194 -
pub async fn tree_root(
195 -
    State(app): State<Shared>,
196 -
    viewer: Viewer,
197 -
    Path((owner, name, r)): Path<(String, String, String)>,
198 -
) -> Result<Page<TreePage>, Error> {
199 -
    tree(State(app), viewer, Path((owner, name, r, String::new()))).await
209 +
pub async fn tree_root(Path((owner, name, r)): Path<(String, String, String)>) -> Response {
210 +
    // A bookmark's or tag's root is the overview with it chosen, side bar
211 +
    // and all (#191).
212 +
    axum::response::Redirect::to(&format!("/{owner}/{name}?at={}", super::settings::urlencode(&r))).into_response()
200 213
}
201 214
202 215
pub async fn tree(
…
360 unchanged lines
modified crates/hub/src/web/wiki.rs +93 −4
…
27 unchanged lines
28 28
    can_edit: bool,
29 29
    last: Option<ChangeView>,
30 30
    notice: Option<String>,
31 +
    /// Every page's slug, for the side bar (#194).
32 +
    pages: Vec<String>,
33 +
    /// How many changes touched this page.
34 +
    revisions: usize,
35 +
    /// The page's headings: (level, text, id).
36 +
    toc: Vec<(u8, String, String)>,
31 37
}
32 38
33 39
#[derive(Template)]
…
84 unchanged lines
118 124
    let e = |e| Error::from_repo(shell.clone(), e);
119 125
    let text = wiki::page(&app, &repo, &slug).await.map_err(e)?;
120 126
    let sidebar = wiki::page(&app, &repo, wiki::SIDEBAR).await.map_err(e)?;
121 -
    let any = !wiki::pages(&app, &repo).await.map_err(e)?.is_empty();
122 -
    let last = if text.is_some() { wiki::history(&app, &repo, &slug, 1).await.map_err(e)?.into_iter().next() } else { None };
123 -
    let rs = RepoShell::new(&app, &row, "wiki").with_role(role);
127 +
    let pages: Vec<String> = wiki::pages(&app, &repo).await.map_err(e)?.into_iter().filter(|p| p != wiki::SIDEBAR).collect();
128 +
    let any = !pages.is_empty();
129 +
    let history = if text.is_some() { wiki::history(&app, &repo, &slug, 1000).await.map_err(e)? } else { vec![] };
130 +
    let revisions = history.len();
131 +
    let last = history.into_iter().next();
132 +
    let rs = RepoShell::new(&app, &row, "wiki").with_role(role).with_sky(&app, &row).await;
124 133
    let can_edit = editable(&row, &rs);
134 +
    let (html, toc) = match text.as_deref().map(|t| contents(&render(&owner, &name, t))) {
135 +
        Some((h, t)) => (Some(h), t),
136 +
        None => (None, vec![]),
137 +
    };
125 138
    Ok(Page(WikiPage {
126 -
        html: text.as_deref().map(|t| render(&owner, &name, t)),
139 +
        html,
140 +
        pages,
141 +
        revisions,
142 +
        toc,
127 143
        sidebar: sidebar.as_deref().map(|t| render(&owner, &name, t)),
128 144
        title: wiki::title_of(&slug),
129 145
        shell,
…
6 unchanged lines
136 152
    }))
137 153
}
138 154
155 +
/// Give a rendered page's `<h1>`–`<h3>` ids and list them, for the
156 +
/// table of contents (#194). The HTML is already sanitised; the ids are
157 +
/// made of letters, digits and dashes only, and the text keeps only what
158 +
/// was between the tags.
159 +
fn contents(html: &str) -> (String, Vec<(u8, String, String)>) {
160 +
    let mut out = String::with_capacity(html.len() + 64);
161 +
    let mut toc = vec![];
162 +
    let mut used = std::collections::HashSet::new();
163 +
    let mut rest = html;
164 +
    while let Some(at) = rest.find("<h") {
165 +
        let level = rest.as_bytes().get(at + 2).copied().unwrap_or(0);
166 +
        let tagged = matches!(level, b'1'..=b'3') && rest[at + 3..].starts_with('>');
167 +
        let close = format!("</h{}>", level as char);
168 +
        let end = if tagged { rest[at..].find(&close).map(|e| at + e) } else { None };
169 +
        let Some(end) = end else {
170 +
            out.push_str(&rest[..at + 2]);
171 +
            rest = &rest[at + 2..];
172 +
            continue;
173 +
        };
174 +
        let inner = &rest[at + 4..end];
175 +
        let mut text = String::new();
176 +
        let mut in_tag = false;
177 +
        for c in inner.chars() {
178 +
            match c {
179 +
                '<' => in_tag = true,
180 +
                '>' => in_tag = false,
181 +
                c if !in_tag => text.push(c),
182 +
                _ => {}
183 +
            }
184 +
        }
185 +
        let mut id: String = text
186 +
            .replace("&amp;", "and")
187 +
            .chars()
188 +
            .map(|c| if c.is_alphanumeric() { c.to_ascii_lowercase() } else { '-' })
189 +
            .collect::<String>()
190 +
            .split('-')
191 +
            .filter(|p| !p.is_empty())
192 +
            .collect::<Vec<_>>()
193 +
            .join("-");
194 +
        if id.is_empty() {
195 +
            id = "section".into();
196 +
        }
197 +
        let mut unique = id.clone();
198 +
        let mut n = 1;
199 +
        while !used.insert(unique.clone()) {
200 +
            n += 1;
201 +
            unique = format!("{id}-{n}");
202 +
        }
203 +
        out.push_str(&rest[..at]);
204 +
        out.push_str(&format!("<h{l} id=\"{unique}\">{inner}{close}", l = level as char));
205 +
        toc.push((level - b'0', text.trim().to_string(), unique));
206 +
        rest = &rest[end + close.len()..];
207 +
    }
208 +
    out.push_str(rest);
209 +
    (out, toc)
210 +
}
211 +
139 212
#[derive(Deserialize)]
140 213
pub struct PagesQuery {
141 214
    #[serde(default)]
…
234 unchanged lines
376 449
        .map_err(|e| Error::from_repo(shell.clone(), e))?
377 450
        .ok_or_else(|| Error::not_found(shell.clone()))?;
378 451
    Ok(super::raw(bytes, &file_name, true))
452 +
}
453 +
454 +
#[cfg(test)]
455 +
mod tests {
456 +
    #[test]
457 +
    fn headings_get_ids_and_a_contents_list() {
458 +
        let (html, toc) = super::contents("<h1>R* API</h1><p>x</p><h2>Build &amp; run</h2><h2>Build &amp; run</h2><h4>deep</h4><hr>");
459 +
        assert_eq!(
460 +
            html,
461 +
            "<h1 id=\"r-api\">R* API</h1><p>x</p><h2 id=\"build-and-run\">Build &amp; run</h2><h2 id=\"build-and-run-2\">Build &amp; run</h2><h4>deep</h4><hr>"
462 +
        );
463 +
        assert_eq!(
464 +
            toc.iter().map(|t| (t.0, t.2.as_str())).collect::<Vec<_>>(),
465 +
            [(1, "r-api"), (2, "build-and-run"), (2, "build-and-run-2")]
466 +
        );
467 +
    }
379 468
}
modified crates/hub/tests/browse.rs +6 −2
…
148 unchanged lines
149 149
    // Tree at a bookmark, a tag and a change; file with highlighting; raw; blame.
150 150
    let t = page(&h.app, "/andy/demo/tree/main/src").await;
151 151
    assert!(t.contains("main.rs"));
152 -
    assert!(page(&h.app, "/andy/demo/tree/v0.1").await.contains("README.md"));
153 -
    assert!(page(&h.app, &format!("/andy/demo/tree/{}", &c1[..10])).await.contains("README.md"));
152 +
    // A ref's root is the overview with it chosen, side bar and all (#191).
153 +
    assert_eq!(get(&h.app, "/andy/demo/tree/v0.1").await.0, StatusCode::SEE_OTHER);
154 +
    let at_tag = page(&h.app, "/andy/demo?at=v0.1").await;
155 +
    assert!(at_tag.contains("README.md") && at_tag.contains("class=\"ref-pick\"") && at_tag.contains("side cards"), "{at_tag}");
156 +
    assert!(at_tag.contains("href=\"/andy/demo?at=v0.1\" class=\"on\""), "the tag is the one chosen");
157 +
    assert!(page(&h.app, &format!("/andy/demo?at={}", &c1[..10])).await.contains("README.md"));
154 158
    assert_eq!(get(&h.app, "/andy/demo/tree/main/nope").await.0, StatusCode::NOT_FOUND);
155 159
    let (hits_before, _, _) = oxygenhub::render::highlight::cache_stats();
156 160
    let f = page(&h.app, "/andy/demo/file/main/src/main.rs").await;
…
139 unchanged lines
modified crates/hub/tests/edits.rs +7 −3
…
404 unchanged lines
405 405
    assert!(detail.contains("\"kind\":\"renamed\""), "{detail}");
406 406
    assert!(send(app, "GET", "/andy/demo/conflicts", None, None).await.body.contains("No bookmark carries a conflict"));
407 407
    assert_eq!(send(app, "GET", "/andy/demo/file/main/new.txt?plain=1", None, None).await.body, "second\n");
408 -
    let tree = send(app, "GET", "/andy/demo/tree/main", None, None).await.body;
408 +
    let tree = send(app, "GET", "/andy/demo?at=main", None, None).await.body;
409 409
    assert!(!tree.contains("oxy-conflict"), "the candidates are gone");
410 410
411 411
    // A replace conflict: a binary file (a NUL makes it opaque) replaced
…
309 unchanged lines
721 721
    assert_eq!(r.location.as_deref(), Some("/andy/demo/wiki/Home"));
722 722
    let home = send(app, "GET", "/andy/demo/wiki", None, None).await.body;
723 723
    assert!(
724 -
        home.contains("Welcome") && home.contains("href=\"/andy/demo/wiki/Guide\"") && home.contains("Last changed by Andy McCall"),
724 +
        home.contains("Welcome")
725 +
            && home.contains("href=\"/andy/demo/wiki/Guide\"")
726 +
            && home.contains("Andy McCall via")
727 +
            && home.contains("edited this page")
728 +
            && home.contains("1 revision"),
725 729
        "{home}"
726 730
    );
727 731
    assert!(send(app, "GET", "/andy/demo", None, None).await.body.contains(">Wiki</a>"), "the tab shows to everyone now");
…
107 unchanged lines
835 839
    assert!(pushed.iter().all(|r| r.ok), "{pushed:?}");
836 840
    assert!(app.wikis.exists(&repo));
837 841
    let home = send(&app, "GET", "/andy/docs/wiki", None, None).await.body;
838 -
    assert!(home.contains("<h1>Docs</h1>") && home.contains("href=\"/andy/docs/wiki/Overview\""), "{home}");
842 +
    assert!(home.contains("<h1 id=\"docs\">Docs</h1>") && home.contains("href=\"/andy/docs/wiki/Overview\""), "{home}");
839 843
    let overview = send(&app, "GET", "/andy/docs/wiki/Overview", None, None).await.body;
840 844
    assert!(overview.contains("class=\"language-mermaid\""), "a diagram block for the page's script to draw: {overview}");
841 845
}
modified crates/hub/tests/proposals.rs +32 −0
…
470 unchanged lines
471 471
    let loaded = oxygenhub::web::proposals::load(app, &repo, p).await.unwrap();
472 472
    let v = oxygenhub::web::proposals::verdict(app, &loaded, 1).await.unwrap();
473 473
    assert!(v.approvals.iter().any(|a| a.account == "andy"), "{:?}", v.approvals.iter().map(|a| &a.account).collect::<Vec<_>>());
474 +
    // Bob asks Claude for a summary (#190): it comes as a comment via Claude.
475 +
    assert!(page.contains("Summarise with Claude"), "{page}");
476 +
    seen.lock().unwrap().clear();
477 +
    let page = get(app, "/andy/demo/proposal/1", Some(&bob)).await.body;
478 +
    let r =
479 +
        act(app, "/andy/demo/proposal/1/assistant", Some(&bob), &format!("_csrf={}&assistant=Claude&task=summary", csrf_of(&page))).await;
480 +
    assert!(r.body.contains("Asked Claude for a summary"), "{}", r.body);
481 +
    let mut comments = vec![];
482 +
    for _ in 0..100 {
483 +
        comments = db::proposals::comments(&app.db, 1).await.unwrap();
484 +
        if comments.iter().any(|c| c.via == "Claude") {
485 +
            break;
486 +
        }
487 +
        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
488 +
    }
489 +
    let summary = comments.iter().find(|c| c.via == "Claude").expect("the assistant's summary arrived");
490 +
    assert_eq!(summary.author, "bob");
491 +
    assert!(summary.body.contains("Adds a third line"));
492 +
    {
493 +
        let calls = seen.lock().unwrap();
494 +
        assert_eq!(calls.len(), 1);
495 +
        let c = &calls[0];
496 +
        assert!(
497 +
            c.contains("called Oxygen")
498 +
                && c.contains("equivalent of a merge request or pull request")
499 +
                && c.contains("Please summarise the proposal"),
500 +
            "{c}"
501 +
        );
502 +
        assert!(c.contains("Review for correctness first.") && c.contains("+three"), "the site's prompt and the context: {c}");
503 +
    }
504 +
    let page = get(app, "/andy/demo/proposal/1", Some(&andy)).await.body;
505 +
    assert!(page.contains("asked Claude for a summary") && page.contains("wrote this for bob"), "{page}");
474 506
    // The API lists the review via Claude and takes requests too.
475 507
    let r = send(app, "GET", "/api/v1/repos/andy/demo/proposals/1/reviews", None, None, None).await;
476 508
    assert!(r.body.contains("\"via\":\"Claude\""), "{}", r.body);
…
215 unchanged lines
modified docs/backgrounds.md +11 −5
…
34 unchanged lines
35 35
| A repository's Diffusion tab | **Ripples** | The upstream and its consumers as the Diffusion mark draws them: a pulse goes out on a bump, green or red as its run ends |
36 36
| Admin › Runners › Images | **The swarm** | Runners as nodes; chunks moving between them while an image is fetched, from the swarm tracker's live state |
37 37
| Not found, server error | **Adrift** | One mark drifting away from the rest |
38 +
| A repository's overview, wiki, proposals and Conflicts tab | **Its sky** | Your sky's scene for that repository alone: its activity of the last 30 days, from the database (#196) |
39 +
| Settings › Keys, Tokens and Backups | **The mark** | As on sign-in: brand only, no data (#196) |
38 40
39 -
**Never:** code, file, blame, diff, conflict and proposal pages, the
40 -
editor, issues and proposals while being written, settings and admin
41 -
tables. Those pages are for reading closely, and motion behind them
42 -
costs attention. Repository overviews have none for the same reason;
43 -
their README is the picture.
41 +
**Never:** file, blame and diff pages, the editor, issues and proposals
42 +
while being written, and admin tables. Those pages are for reading
43 +
closely, and motion behind them costs attention.
44 44
45 +
On 2026-10-14 Andy chose to give backdrops to the pages of that day's
46 +
polish batch as well (#196): a repository's overview, wiki, proposals
47 +
and Conflicts tab, and Settings › Keys, Tokens and Backups. They keep the
48 +
rules above: the content is opaque and marked `data-backdrop-avoid`, so
49 +
a scene shows only in the margins, and anyone can turn backdrops off.
50 +
45 51
Each scene is its own issue, done one at a time, the dashboard first.
46 52
47 53
## 3. Colours
…
204 unchanged lines
modified templates/backups.html +4 −0
…
2 unchanged lines
3 3
{% block title %}Backups{% if kind == "org" %} · {{ owner }}{% endif %}{% endblock %}
4 4
{% block tabs %}{% if kind == "user" %}{% call t::tabs("backups") %}{% endif %}{% endblock %}
5 5
{% block content %}
6 +
<script type="application/json" id="backdrop-data">{"scene":"mark"}</script>
7 +
{% include "backdrop.html" %}
8 +
<div data-backdrop-avoid>
6 9
<div class="head"><h1>Backups <small>{% if kind == "org" %}<a href="/orgs/{{ owner }}/settings">{{ owner }}</a>'s repositories{% else %}your repositories{% endif %}, copied to a place you control</small></h1></div>
7 10
{% if let Some(n) = notice %}<p class="note">{{ n }}</p>{% endif %}
8 11
{% if let Some(e) = error %}<p class="error">{{ e }}</p>{% endif %}
…
58 unchanged lines
67 70
  </table></div>
68 71
  {% endif %}
69 72
</section>
73 +
</div>
70 74
{% endblock %}
modified templates/base.html +1 −1
…
17 unchanged lines
18 18
  <nav aria-label="Account">
19 19
    <button type="button" class="theme-toggle" data-theme-toggle aria-label="Theme" title="Theme"><span aria-hidden="true">◐</span></button>
20 20
    {% if let Some(u) = shell.user %}
21 -
    <a class="go" href="/new" title="New repository">+ NEW</a>
21 +
    <a class="go primary" href="/new" title="New repository">+ NEW</a>
22 22
    <a class="bell" href="/notifications" title="Notifications">✉{% if shell.unread > 0 %}<span class="count">{{ shell.unread }}</span>{% endif %}</a>
23 23
    <a class="me" href="/{{ u }}"><img class="avatar xs" src="/avatar/{{ u }}" alt=""> {{ u }}</a>
24 24
    <form class="inline" method="post" action="/logout"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><button>SIGN OUT</button></form>
…
39 unchanged lines
modified templates/conflicts.html +3 −1
…
2 unchanged lines
3 3
{% block title %}Conflicts · {{ repo.owner }}/{{ repo.name }}{% endblock %}
4 4
{% block bodyattrs %} data-repo="{{ repo.url }}"{% endblock %}
5 5
{% block tabs %}{% call m::tabs(repo, "conflicts") %}{% endblock %}
6 -
{% block mainclass %} class="wide"{% endblock %}
7 6
{% block content %}
7 +
{% include "repo_backdrop.html" %}
8 +
<div data-backdrop-avoid>
8 9
<div class="head"><h1>Conflicts <small>{{ conflicts.len() }}</small></h1></div>
9 10
{% if conflicts.is_empty() %}<p class="empty">No bookmark carries a conflict. Conflicts are values in Oxygen: when two changes disagree, the state records both sides and any collaborator can push a resolution.</p>{% else %}
10 11
{% for c in conflicts %}
…
7 unchanged lines
18 19
</section>
19 20
{% endfor %}
20 21
{% endif %}
22 +
</div>
21 23
{% endblock %}
modified templates/overview.html +23 −11
1 1
{% extends "base.html" %}
2 2
{% import "repo_tabs.html" as m %}
3 3
{% block title %}{{ repo.owner }}/{{ repo.name }}{% endblock %}
4 -
{% block bodyattrs %} data-repo="{{ repo.url }}" data-ref="{{ repo.default_bookmark }}"{% endblock %}
4 +
{% block bodyattrs %} data-repo="{{ repo.url }}" data-ref="{{ at }}"{% endblock %}
5 5
{% block tabs %}{% call m::tabs(repo, "code") %}{% endblock %}
6 6
{% block content %}
7 +
{% include "repo_backdrop.html" %}
8 +
<div data-backdrop-avoid>
7 9
<div class="side-layout rev">
8 10
  <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 %}
11 +
    {% if o.heads > 1 %}<div class="strip warn">{{ at }} has {{ o.heads }} heads; the tree below is their union. <a href="{{ repo.url }}/bookmarks">Bookmarks</a></div>{% endif %}
10 12
    {% if o.conflicted %}<div class="strip bad">This state carries conflicts. <a href="{{ repo.url }}/conflicts">See them</a></div>{% endif %}
11 13
    <div class="bar">
12 -
      <span class="ref px"><a href="{{ repo.url }}/bookmarks">◆ {{ repo.default_bookmark }}</a></span>
13 -
      {% if o.latest.is_none() %}<a class="go small" href="{{ repo.url }}/changes/{{ repo.default_bookmark }}">{{ o.changes|plural("change", "changes") }}</a>{% endif %}
14 -
      {% if repo.can(crate::auth::Access::Write) %}<a class="go small" href="{{ repo.url }}/newfile/{{ repo.default_bookmark }}">new file</a>{% endif %}
14 +
      <details class="ref-pick">
15 +
        <summary class="go small" aria-haspopup="true" title="Choose a bookmark or tag"><span aria-hidden="true">{% if o.tree.target.kind == crate::repos::view::RefKind::Tag %}🏷{% else %}◆{% endif %}</span> {{ at }} <span aria-hidden="true">▾</span></summary>
16 +
        <div class="ref-panel" role="menu">
17 +
          <p class="small mute">Bookmarks</p>
18 +
          <ul class="plain">{% for b in o.bookmarks %}<li><a role="menuitem" href="{{ repo.url }}{% if b.name != repo.default_bookmark %}?at={{ b.name|urlencode }}{% endif %}"{% if b.name == at %} class="on"{% endif %}>◆ {{ b.name }}{% if b.name == repo.default_bookmark %} <span class="tag">default</span>{% endif %}</a></li>{% endfor %}</ul>
19 +
          {% if !o.tags.is_empty() %}<p class="small mute">Tags</p>
20 +
          <ul class="plain">{% for t in o.tags %}<li><a role="menuitem" href="{{ repo.url }}?at={{ t.name|urlencode }}"{% if t.name == at %} class="on"{% endif %}>🏷 {{ t.name }}</a></li>{% endfor %}</ul>{% endif %}
21 +
          <p class="small"><a href="{{ repo.url }}/bookmarks">All bookmarks ›</a></p>
22 +
        </div>
23 +
      </details>
24 +
      {% if o.latest.is_none() %}<a class="go small" href="{{ repo.url }}/changes/{{ at }}">{{ o.changes|plural("change", "changes") }}</a>{% endif %}
25 +
      {% if repo.can(crate::auth::Access::Write) %}<a class="go small" href="{{ repo.url }}/newfile/{{ at }}">new file</a>{% endif %}
15 26
      <form class="find inline-find" method="get" action="{{ repo.url }}/search"><input type="search" name="q" placeholder="Search code…"></form>
16 27
      <details class="clone-pop">
17 28
        <summary class="go small primary" aria-haspopup="true"><span aria-hidden="true">&lt;&gt;</span> Code</summary>
…
2 unchanged lines
20 31
          <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 32
          <p class="small mute">The URL alone: <code>{{ repo.clone_url }}</code></p>
22 33
          {% if quickstart.is_none() %}
23 -
          <a class="download" href="{{ repo.url }}/archive?ref={{ repo.default_bookmark|urlencode }}&amp;format=zip" download><span aria-hidden="true">⤓</span> Download ZIP</a>
24 -
          <p class="small mute">{{ repo.default_bookmark }} as it is now, without its history. Also as <a href="{{ repo.url }}/archive?ref={{ repo.default_bookmark|urlencode }}&amp;format=tar.zst" download>.tar.zst</a>.</p>
34 +
          <a class="download" href="{{ repo.url }}/archive?ref={{ at|urlencode }}&amp;format=zip" download><span aria-hidden="true">⤓</span> Download ZIP</a>
35 +
          <p class="small mute">{{ at }} as it is now, without its history. Also as <a href="{{ repo.url }}/archive?ref={{ at|urlencode }}&amp;format=tar.zst" download>.tar.zst</a>.</p>
25 36
          {% endif %}
26 37
        </div>
27 38
      </details>
…
4 unchanged lines
32 43
    <div class="tree-box">
33 44
    {% if let Some(c) = o.latest %}<div class="tree-head">
34 45
      <span class="tree-title">{% if let Some(a) = c.author.account %}<a class="who" href="/{{ a }}"><img class="avatar" src="/avatar/{{ a }}" alt="" width="20" height="20"> {{ a }}</a>{% else %}<span class="who">{{ c.author.name }}</span>{% endif %} <a class="t" href="{{ repo.url }}/change/{{ c.hash }}" title="{{ c.title }}">{{ c.title }}</a></span>
35 -
      <span class="tree-meta"><a href="{{ repo.url }}/change/{{ c.hash }}"><code>{{ c.short }}</code></a><time class="ago" title="{{ c.when_ms|when }}">{{ c.when_ms|ago }}</time><a class="hist" href="{{ repo.url }}/changes/{{ repo.default_bookmark }}">{{ crate::web::icons::svg("history")|safe }} {{ o.changes|plural("change", "changes") }}</a></span>
46 +
      <span class="tree-meta"><a href="{{ repo.url }}/change/{{ c.hash }}"><code>{{ c.short }}</code></a><time class="ago" title="{{ c.when_ms|when }}">{{ c.when_ms|ago }}</time><a class="hist" href="{{ repo.url }}/changes/{{ at }}">{{ crate::web::icons::svg("history")|safe }} {{ o.changes|plural("change", "changes") }}</a></span>
36 47
    </div>{% endif %}
37 48
    {% include "tree_table.html" %}
38 49
    </div>
…
7 unchanged lines
46 57
    {% if repo.description.is_empty() %}<p class="mute small">No description.</p>{% else %}<p>{{ repo.description }}</p>{% endif %}
47 58
    {% if let Some(up) = fork.upstream %}<p class="small">forked from <a href="/{{ up.owner }}/{{ up.name }}">{{ up.owner }}/{{ up.name }}</a></p>{% endif %}
48 59
    {% if !topics.is_empty() %}<p class="topics">{% for t in topics %}<a class="topic" href="/explore?topic={{ t }}">{{ t }}</a>{% endfor %}</p>{% endif %}
49 -
    {% if let Some(l) = o.license %}<p class="small">Licence: <a href="{{ repo.url }}/file/{{ repo.default_bookmark }}/{{ l }}">{{ l }}</a></p>{% endif %}
60 +
    {% if let Some(l) = o.license %}<p class="small">Licence: <a href="{{ repo.url }}/file/{{ at }}/{{ l }}">{{ l }}</a></p>{% endif %}
50 61
    {% if shell.user.is_some() %}<div class="about-actions">
51 62
      <form class="inline" method="post" action="{{ repo.url }}/star"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><button type="submit" class="small">{% if starred %}★ UNSTAR{% else %}☆ STAR{% endif %}</button></form>
52 63
      <form class="inline" method="post" action="{{ repo.url }}/watch"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><button type="submit" class="small">{% if watching %}UNWATCH{% else %}WATCH{% endif %}</button></form>
…
31 unchanged lines
84 95
    </div></section>
85 96
    {% endif %}
86 97
    <section class="side-card"><h3>Bookmarks <span class="count-chip">{{ o.bookmarks.len() }}</span>{% if o.bookmarks.len() > 10 %}<a class="more" href="{{ repo.url }}/bookmarks">all {{ o.bookmarks.len() }} ›</a>{% endif %}</h3><div class="body">
87 -
    <ul class="plain bookmarks">{% for b in o.recent_bookmarks() %}<li><a href="{{ repo.url }}/tree/{{ b.name }}" title="{{ 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 %}<time class="small mute" title="{{ b.latest_ms|when }}">{{ b.latest_ms|ago }}</time></li>{% endfor %}</ul>
98 +
    <ul class="plain bookmarks">{% for b in o.recent_bookmarks() %}<li><a href="{{ repo.url }}?at={{ b.name|urlencode }}" title="{{ 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 %}<time class="small mute" title="{{ b.latest_ms|when }}">{{ b.latest_ms|ago }}</time></li>{% endfor %}</ul>
88 99
    </div></section>
89 100
    <section class="side-card"><h3>Tags</h3><div class="body">
90 -
    {% if o.tags.is_empty() %}<p class="mute small">None.</p>{% else %}<ul class="plain">{% for t in o.tags %}<li><a href="{{ repo.url }}/tree/{{ t.name }}">{{ t.name }}</a>{% if t.signer.is_some() %} <span class="sig sig-valid" title="signed">✔</span>{% endif %}</li>{% endfor %}</ul>{% endif %}
101 +
    {% if o.tags.is_empty() %}<p class="mute small">None.</p>{% else %}<ul class="plain">{% for t in o.tags %}<li><a href="{{ repo.url }}?at={{ t.name|urlencode }}">{{ t.name }}</a>{% if t.signer.is_some() %} <span class="sig sig-valid" title="signed">✔</span>{% endif %}</li>{% endfor %}</ul>{% endif %}
91 102
    </div></section>
92 103
    <section class="side-card"><h3>Trust</h3><div class="body">
93 104
    {% match o.policy %}
…
3 unchanged lines
97 108
    {% endmatch %}
98 109
    </div></section>
99 110
  </aside>
111 +
</div>
100 112
</div>
101 113
{% endblock %}
modified templates/proposal.html +5 −2
…
4 unchanged lines
5 5
{% block tabs %}{% call m::tabs(repo, "proposals") %}{% endblock %}
6 6
{% block mainclass %}{% if tab != "conversation" %} class="wide"{% endif %}{% endblock %}
7 7
{% block content %}
8 +
{% include "repo_backdrop.html" %}
9 +
<div data-backdrop-avoid>
8 10
<div class="head"><h1>{{ p.title }}{% if can_edit %} <a class="head-pencil" href="#edit" title="Edit title, target and description" aria-label="Edit title, target and description"><svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path fill="currentColor" d="M11.01 1.31a1.75 1.75 0 0 1 2.48 0l1.2 1.2a1.75 1.75 0 0 1 0 2.48L5.1 14.6a1.75 1.75 0 0 1-.8.46l-3.13.88a.75.75 0 0 1-.92-.92l.88-3.13a1.75 1.75 0 0 1 .46-.8Zm1.42 1.06a.25.25 0 0 0-.36 0L10.5 3.94 12.06 5.5l1.57-1.57a.25.25 0 0 0 0-.36ZM11 6.56 9.44 5 3.15 11.3a.25.25 0 0 0-.06.11l-.55 1.95 1.95-.55a.25.25 0 0 0 .11-.06Z"/></svg></a>{% endif %} <small class="px kind-proposal">proposal #{{ p.number }}</small>
9 11
  <small>
10 12
    <span class="tag{% if p.state == "landed" %} head{% else if p.state == "closed" %} bad{% endif %}">{{ p.state }}</span>
…
92 unchanged lines
103 105
  <div class="tl-item">
104 106
    <img class="tl-avatar" src="/avatar/{{ c.row.author }}" alt="" width="36" height="36">
105 107
    <div class="tl-card">
106 -
      <div class="tl-head"><a href="/{{ c.row.author }}"><strong>{{ c.row.author }}</strong></a> commented <time title="{{ c.row.created_ms|when_i }}">{{ c.row.created_ms|ago_i }}</time></div>
108 +
      <div class="tl-head"><a href="/{{ c.row.author }}"><strong>{{ c.row.author }}</strong></a>{% if !c.row.via.is_empty() %} <span class="mute" aria-hidden="true">↳</span> <span class="tag" title="An AI assistant wrote this for {{ c.row.author }}, who asked it">agent</span> <img class="avatar xs" src="/avatar/agent/{{ c.row.via }}" alt="" width="16" height="16"> <strong>{{ c.row.via }}</strong>{% endif %} commented <time title="{{ c.row.created_ms|when_i }}">{{ c.row.created_ms|ago_i }}</time></div>
107 109
      <div class="tl-text">{{ c.body_html|safe }}</div>
108 110
    </div>
109 111
  </div>
…
94 unchanged lines
204 206
        <div class="menu-foot"><button type="submit" class="small primary-btn">Apply</button></div>
205 207
      </form></details>{% endif %}</div>
206 208
    {% if requested.is_empty() %}<p class="side-empty">No reviews requested</p>{% else %}<ul class="side-people">{% for r in requested %}<li><a href="/{{ r }}"><img class="avatar xs" src="/avatar/{{ r }}" alt="" loading="lazy"> {{ r }}</a> <span class="side-note-inline">awaiting review</span></li>{% endfor %}</ul>{% endif %}
207 -
    {% if !assistants.is_empty() %}<div class="side-assist">{% for a in assistants %}<form class="tiny inline" method="post" action="{{ repo.url }}/proposal/{{ p.number }}/assistant"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><input type="hidden" name="assistant" value="{{ a }}"><button type="submit" class="agent-btn" title="Ask {{ a }}, an AI assistant, to review; its answer is recorded as your review via {{ a }}"><img src="/avatar/agent/{{ a }}" alt="" width="16" height="16"> Ask {{ a }}</button></form>{% endfor %}</div>{% endif %}
209 +
    {% if !assistants.is_empty() %}<div class="side-assist">{% for a in assistants %}<form class="tiny inline" method="post" action="{{ repo.url }}/proposal/{{ p.number }}/assistant"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><input type="hidden" name="assistant" value="{{ a }}"><button type="submit" class="agent-btn" title="Ask {{ a }}, an AI assistant, to review; its answer is recorded as your review via {{ a }}"><img src="/avatar/agent/{{ a }}" alt="" width="16" height="16"> Ask {{ a }}</button> <button type="submit" class="agent-btn" name="task" value="summary" title="{{ a }}, an AI assistant, summarises the proposal; its summary is posted as your comment via {{ a }}"><img src="/avatar/agent/{{ a }}" alt="" width="16" height="16"> Summarise with {{ a }}</button></form>{% endfor %}</div>{% endif %}
208 210
  </section>
209 211
  <section class="side-sec">
210 212
    <div class="side-head"><h3>Labels</h3>
…
36 unchanged lines
247 249
</aside>
248 250
</div>
249 251
{% endif %}
252 +
</div>
250 253
{% endblock %}
modified templates/settings_keys.html +31 −16
…
2 unchanged lines
3 3
{% block title %}Keys{% endblock %}
4 4
{% block tabs %}{% call t::tabs("keys") %}{% endblock %}
5 5
{% block content %}
6 +
<script type="application/json" id="backdrop-data">{"scene":"mark"}</script>
7 +
{% include "backdrop.html" %}
8 +
<div data-backdrop-avoid>
6 9
<div class="head"><h1>Keys <small>{{ me.username }} · Oxygen identities linked to your account</small></h1></div>
7 10
<p class="lead">A change is yours on every page when it is signed by a key you have proven you hold. Export your identity with <code>oxy identity export</code>, upload the file, then sign the challenge the hub gives you.</p>
8 11
{% if let Some(n) = notice %}<p class="note">{{ n }}</p>{% endif %}
9 12
{% if let Some(e) = error %}<p class="error">{{ e }}</p>{% endif %}
10 13
{% if let Some((id, bytes, name)) = pending %}
11 -
<form class="card wide" method="post" action="/settings/keys/verify">
14 +
<form class="card wide keys-verify" method="post" action="/settings/keys/verify">
12 15
  <input type="hidden" name="_csrf" value="{{ shell.csrf }}">
13 16
  <input type="hidden" name="challenge" value="{{ id }}">
14 17
  <h2>Prove you hold the key of {{ name }}</h2>
…
3 unchanged lines
18 21
  <button type="submit">LINK KEY</button>
19 22
</form>
20 23
{% endif %}
21 -
{% if keys.is_empty() %}<p class="empty">No keys linked yet.</p>{% else %}
22 -
<div class="table"><table>
23 -
<tr><th>Key</th><th>Identity</th><th>Linked</th><th></th></tr>
24 -
{% for k in keys %}
25 -
<tr><td><code>{{ k.key|short }}</code></td><td>{{ k.name }} <span class="mute">{{ k.email }}</span></td><td><time title="{{ k.linked_ms|when_i }}">{{ k.linked_ms|ago_i }}</time></td>
26 -
<td><form class="tiny" method="post" action="/settings/keys/unlink"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><input type="hidden" name="key" value="{{ k.key }}"><button>unlink</button></form></td></tr>
27 -
{% endfor %}
28 -
</table></div>
29 -
{% endif %}
30 -
<form class="card" method="post" action="/settings/keys/upload" enctype="multipart/form-data">
31 -
  <input type="hidden" name="_csrf" value="{{ shell.csrf }}">
32 -
  <h2>Link a key</h2>
33 -
  <label for="identity">Identity file (from <code>oxy identity export</code>)</label><input type="file" id="identity" name="identity" required>
34 -
  <button type="submit">UPLOAD</button>
35 -
</form>
24 +
<section class="card-box">
25 +
  <div class="card-head"><h2>Linked keys</h2><span class="count-chip">{{ keys.len() }}</span></div>
26 +
  {% if keys.is_empty() %}<p class="empty small">No keys linked yet.</p>{% else %}
27 +
  <ul class="rows token-rows">
28 +
  {% for k in keys %}
29 +
    <li>
30 +
      <div class="token-main">
31 +
        <div class="token-name"><code>{{ k.key|short }}</code> <strong>{{ k.name }}</strong> <span class="mute">{{ k.email }}</span></div>
32 +
        <div class="small mute">linked <time title="{{ k.linked_ms|when_i }}">{{ k.linked_ms|ago_i }}</time></div>
33 +
      </div>
34 +
      <div class="token-actions">
35 +
        <form class="inline" method="post" action="/settings/keys/unlink"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><input type="hidden" name="key" value="{{ k.key }}"><button class="small danger" title="Changes it signs stop counting as yours">Unlink</button></form>
36 +
      </div>
37 +
    </li>
38 +
  {% endfor %}
39 +
  </ul>
40 +
  {% endif %}
41 +
</section>
42 +
<section class="card-box">
43 +
  <div class="card-head"><h2>Link a key</h2></div>
44 +
  <form class="pad token-form" method="post" action="/settings/keys/upload" enctype="multipart/form-data">
45 +
    <input type="hidden" name="_csrf" value="{{ shell.csrf }}">
46 +
    <label for="identity">Identity file (from <code>oxy identity export</code>)</label><input type="file" id="identity" name="identity" required>
47 +
    <button type="submit">UPLOAD</button>
48 +
  </form>
49 +
</section>
50 +
</div>
36 51
{% endblock %}
modified templates/settings_tokens.html +4 −0
…
2 unchanged lines
3 3
{% block title %}Tokens{% endblock %}
4 4
{% block tabs %}{% call t::tabs("tokens") %}{% endblock %}
5 5
{% block content %}
6 +
<script type="application/json" id="backdrop-data">{"scene":"mark"}</script>
7 +
{% include "backdrop.html" %}
8 +
<div data-backdrop-avoid>
6 9
<div class="head"><h1>Personal access tokens <small>{{ me.username }} · for oxy and the API</small></h1></div>
7 10
{% if let Some(n) = notice %}<p class="note">{{ n }}</p>{% endif %}
8 11
{% if let Some(e) = error %}<p class="error">{{ e }}</p>{% endif %}
…
40 unchanged lines
49 52
    <button type="submit">CREATE TOKEN</button>
50 53
  </form>
51 54
</section>
55 +
</div>
52 56
{% endblock %}
modified templates/style.css +56 −0
…
1188 unchanged lines
1189 1189
.token-form .token-agent { margin-top: 8px; }
1190 1190
.token-form .token-hint { margin: 0 0 0 24px; }
1191 1191
@media (max-width: 700px) { .token-rows li { flex-direction: column; } }
1192 +
/* Settings › Keys (oxygenhub#188) shares the token rows; Backups' form is as wide as its Runs (#189). */
1193 +
form.card.keys-verify { max-width: none; }
1194 +
form.card.backup-form { max-width: none; }
1195 +
/* Wiki clone box (oxygenhub#192): the command wraps inside its card. */
1196 +
.side-sec pre.snippet { white-space: pre-wrap; overflow-wrap: anywhere; margin: 0; }
1197 +
/* The overview's bookmark and tag picker (oxygenhub#191). */
1198 +
details.ref-pick { position: relative; }
1199 +
details.ref-pick > summary { list-style: none; cursor: pointer; }
1200 +
details.ref-pick > summary::-webkit-details-marker { display: none; }
1201 +
.ref-panel { position: absolute; z-index: 30; top: calc(100% + 4px); left: 0; min-width: 240px; max-width: min(360px, 90vw); max-height: 60vh; overflow: auto; background: var(--panel); border: 1px solid var(--line); border-radius: 6px; box-shadow: 0 8px 24px rgba(0,0,0,.15); padding: 8px 0; }
1202 +
.ref-panel p { margin: 4px 12px; }
1203 +
.ref-panel ul { margin: 0; }
1204 +
.ref-panel li a { display: block; padding: 4px 12px; overflow-wrap: anywhere; }
1205 +
.ref-panel li a:hover, .ref-panel li a.on { background: var(--soft); text-decoration: none; }
1206 +
.ref-panel li a.on { font-weight: 600; }
1207 +
/* The Markdown editor in GitLab's manner (oxygenhub#193): one bordered box,
1208 +
   a Preview tab, icon buttons, a footer. */
1209 +
.EasyMDEContainer { border: 1px solid var(--line); border-radius: 6px; background: var(--bg); overflow: hidden; }
1210 +
.EasyMDEContainer:focus-within { border-color: var(--acc); box-shadow: 0 0 0 2px color-mix(in srgb, var(--acc) 25%, transparent); }
1211 +
.EasyMDEContainer .editor-toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: 2px; border: 0; border-bottom: 1px solid var(--line); border-radius: 0; background: var(--panel); padding: 4px 6px; }
1212 +
.EasyMDEContainer .editor-toolbar i.separator { border-left: 1px solid var(--line); border-right: 0; height: 18px; margin: 0 4px; }
1213 +
.EasyMDEContainer .editor-toolbar button.mde-btn { border: 0; background: transparent; display: inline-flex; align-items: center; justify-content: center; width: 30px; height: 28px; padding: 0; border-radius: 6px; color: var(--mute); }
1214 +
.EasyMDEContainer .editor-toolbar button.mde-btn.mde-text { width: auto; padding: 0 10px; font-size: 13px; font-weight: 500; color: var(--ink); }
1215 +
.EasyMDEContainer .editor-toolbar button.mde-btn:hover, .EasyMDEContainer .editor-toolbar button.mde-btn.active { background: var(--soft); color: var(--ink); }
1216 +
.EasyMDEContainer .editor-toolbar button.fullscreen { margin-left: auto; }
1217 +
.EasyMDEContainer .editor-toolbar .mde-ico { display: inline-flex; }
1218 +
.EasyMDEContainer .CodeMirror { border: 0; border-radius: 0; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 13px; }
1219 +
.EasyMDEContainer .CodeMirror .CodeMirror-placeholder { color: var(--mute); }
1220 +
.EasyMDEContainer .editor-preview { background: var(--bg); color: var(--ink); }
1221 +
.mde-foot { display: flex; justify-content: space-between; align-items: center; padding: 4px 10px; border-top: 1px solid var(--line); background: var(--panel); }
1222 +
.mde-md { font-weight: 700; border: 1px solid currentColor; border-radius: 3px; padding: 0 3px; font-size: 10px; }
1223 +
.EasyMDEContainer .editor-toolbar.fullscreen, .EasyMDEContainer .CodeMirror-fullscreen, .EasyMDEContainer .editor-preview-active-side { background: var(--bg); }
1224 +
/* Wiki pages (oxygenhub#194): a title row with who edited it and the
1225 +
   buttons, the page as a card, pages and contents in the side bar. */
1226 +
.wiki-top { display: flex; flex-wrap: wrap; align-items: flex-start; justify-content: space-between; gap: 12px; padding-bottom: 12px; margin-bottom: 16px; border-bottom: 1px solid var(--line); }
1227 +
.wiki-title { min-width: 0; }
1228 +
.wiki-title h1 { margin: 0 0 4px; text-wrap: balance; }
1229 +
.wiki-title p { margin: 0; }
1230 +
.wiki-actions { display: flex; gap: 8px; flex-shrink: 0; }
1231 +
article.wiki-doc.card-doc { border: 1px solid var(--line); border-radius: 6px; background: var(--panel); padding: 20px 28px; }
1232 +
.wiki-foot { margin-top: 8px; }
1233 +
.wiki-side-col .side-sec { min-width: 0; }
1234 +
details.wiki-pages > summary { list-style: none; cursor: pointer; }
1235 +
details.wiki-pages > summary::-webkit-details-marker { display: none; }
1236 +
details.wiki-pages > summary h3::before { content: "▸ "; color: var(--mute); }
1237 +
details.wiki-pages[open] > summary h3::before { content: "▾ "; }
1238 +
.wiki-pages ul, .wiki-toc { margin: 0; padding: 6px 12px; }
1239 +
.wiki-pages li a, .wiki-toc li a { display: block; padding: 2px 0; overflow-wrap: anywhere; }
1240 +
.wiki-pages li a.on { font-weight: 600; }
1241 +
.wiki-pages p { margin: 0 12px 8px; }
1242 +
.wiki-toc li.lv2 { padding-left: 12px; }
1243 +
.wiki-toc li.lv3 { padding-left: 24px; font-size: 13px; }
1244 +
.wiki-side-col .clone { display: flex; gap: 6px; padding: 8px 12px; }
1245 +
.wiki-side-col .clone input { flex: 1; min-width: 0; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 12px; }
1246 +
.wiki-doc h1[id], .wiki-doc h2[id], .wiki-doc h3[id] { scroll-margin-top: 16px; }
1247 +
.EasyMDEContainer .editor-toolbar button.mde-btn.mde-text, .EasyMDEContainer .editor-toolbar button.mde-btn.mde-text::first-letter { text-transform: none; }
modified templates/wiki.html +27 −7
…
3 unchanged lines
4 4
{% block bodyattrs %} data-repo="{{ repo.url }}"{% endblock %}
5 5
{% block tabs %}{% call m::tabs(repo, "wiki") %}{% endblock %}
6 6
{% block content %}
7 +
{% include "repo_backdrop.html" %}
8 +
<div data-backdrop-avoid>
7 9
{% if let Some(n) = notice %}<p class="note">{{ n }}</p>{% endif %}
10 +
<div class="wiki-top">
11 +
  <div class="wiki-title">
12 +
    <h1>{{ title }}</h1>
13 +
    {% if let Some(c) = last %}<p class="small mute">{{ c.author.name }}{% if let Some(v) = c.via %} via {{ v.name }}{% endif %} edited this page <time title="{{ c.when_ms|when_i }}">{{ c.when_ms|ago_i }}</time> · <a href="{{ repo.url }}/wiki/{{ slug }}/history">{{ revisions|plural("revision", "revisions") }}</a></p>{% endif %}
14 +
  </div>
15 +
  <div class="wiki-actions">
16 +
    {% if can_edit %}{% if html.is_some() %}<a class="go" href="{{ repo.url }}/wiki/{{ slug }}/edit">Edit</a>{% endif %}<a class="go primary" href="{{ repo.url }}/wiki/_new">New page</a>{% endif %}
17 +
  </div>
18 +
</div>
8 19
<div class="side-layout wiki">
9 20
<div>
10 -
  <div class="head"><h1>{{ title }}</h1>
11 -
    <p class="small">{% if can_edit %}{% if html.is_some() %}<a class="go" href="{{ repo.url }}/wiki/{{ slug }}/edit">Edit</a> {% endif %}<a class="go" href="{{ repo.url }}/wiki/_new">New page</a> {% endif %}<a class="go" href="{{ repo.url }}/wiki/_pages">Pages</a>{% if html.is_some() %} <a class="go" href="{{ repo.url }}/wiki/{{ slug }}/history">History</a>{% endif %}</p>
12 -
  </div>
13 21
  {% if let Some(h) = html %}
14 -
  <article class="doc wiki-doc">{{ h|safe }}</article>
15 -
  {% if let Some(c) = last %}<p class="small mute">Last changed by {{ c.author.name }}{% if let Some(v) = c.via %} via {{ v.name }}{% endif %} <time title="{{ c.when_ms|when_i }}">{{ c.when_ms|ago_i }}</time>{% if can_edit %} · <form class="tiny inline" method="post" action="{{ repo.url }}/wiki/{{ slug }}/delete" onsubmit="return confirm('Delete the page {{ title }}?')"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><button class="linkish">delete this page</button></form>{% endif %}</p>{% endif %}
22 +
  <article class="doc wiki-doc card-doc">{{ h|safe }}</article>
23 +
  {% if can_edit %}<p class="small mute wiki-foot"><form class="tiny inline" method="post" action="{{ repo.url }}/wiki/{{ slug }}/delete" onsubmit="return confirm('Delete the page {{ title }}?')"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><button class="linkish">Delete this page</button></form></p>{% endif %}
16 24
  {% else if any %}
17 25
  <p class="empty">There is no page called <strong>{{ title }}</strong> yet.{% if can_edit %} <a href="{{ repo.url }}/wiki/_new?title={{ title }}">Create it</a>.{% endif %}</p>
18 26
  {% else %}
…
6 unchanged lines
25 33
  </section>
26 34
  {% endif %}
27 35
</div>
28 -
<aside class="side side-sections">
36 +
<aside class="side side-sections wiki-side-col">
37 +
  {% if any %}<section class="side-sec">
38 +
    <details class="wiki-pages"{% if sidebar.is_none() %} open{% endif %}><summary class="side-head"><h3>Pages <span class="count-chip">{{ pages.len() }}</span></h3></summary>
39 +
      <ul class="plain">{% for p in pages %}<li><a href="{{ repo.url }}/wiki/{{ p }}"{% if *p == slug %} class="on"{% endif %}>{{ crate::wiki::title_of(p) }}</a></li>{% endfor %}</ul>
40 +
      <p class="small"><a href="{{ repo.url }}/wiki/_pages">Search the pages ›</a></p>
41 +
    </details>
42 +
  </section>{% endif %}
43 +
  {% if toc.len() > 1 %}<section class="side-sec"><div class="side-head"><h3>Contents</h3></div>
44 +
    <ul class="plain wiki-toc">{% for (level, text, id) in toc %}<li class="lv{{ level }}"><a href="#{{ id }}">{{ text|safe }}</a></li>{% endfor %}</ul>
45 +
  </section>{% endif %}
29 46
  {% if let Some(s) = sidebar %}<section class="side-sec"><div class="doc wiki-side">{{ s|safe }}</div></section>{% endif %}
30 -
  <section class="side-sec"><div class="side-head"><h3>Clone this wiki</h3></div><pre class="snippet small">oxy clone {{ shell.base_url }}/{{ repo.owner }}/{{ repo.name }}.wiki</pre></section>
47 +
  <section class="side-sec"><div class="side-head"><h3>Clone this wiki</h3></div>
48 +
    <div class="clone"><input type="text" readonly value="oxy clone {{ shell.base_url }}/{{ repo.owner }}/{{ repo.name }}.wiki" onfocus="this.select()" onclick="this.select()" aria-label="Clone command"><button type="button" class="small" data-copy="oxy clone {{ shell.base_url }}/{{ repo.owner }}/{{ repo.name }}.wiki">copy</button></div>
49 +
  </section>
31 50
</aside>
51 +
</div>
32 52
</div>
33 53
{% endblock %}