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("&", "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 & run</h2><h2>Build & 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 & run</h2><h2 id=\"build-and-run-2\">Build & 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"><></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 }}&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 }}&format=tar.zst" download>.tar.zst</a>.</p> |
|
| 34 | + | <a class="download" href="{{ repo.url }}/archive?ref={{ at|urlencode }}&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 }}&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 %} |