OXYGENHUB

New repository page in sections, with OxygenFlow, the wiki and a default bookmark 1062a0f8e985

The page is laid out as Repository, Visibility, Starting files, Wiki,
Project board, OxygenFlow and Storage, each with a heading, and the
choices are cards side by side that stack on a phone.

- OxygenFlow: on the site's runners (site administrators) or the
  owner's own (when it has one), checked before anything is made.
- Wiki: start it with a Home page giving the name and description.
- Default bookmark: chosen on the page, else the owner's default, else
  the site's, else main. Users set theirs under Settings, organisations
  under their settings, administrators under Administration.
- API create takes flow, wiki and default_bookmark; oh repo create
  takes --flow, --wiki and --bookmark.

Closes #371
Author
Andy McCall @andymccall <[email protected]>
via
agent agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key ec728b340028
When
· 2026-11-05 00:14 UTC
Signature
◈ sealed by agent:claude@andymccall
Hash
1062a0f8e985bb8c65ab90ccae6309f38730152b13ca9e9c579d6f5751053161
Depends on
17ca6cddc534 19bb31ff1f5c 1f78655a0466 34beb1a7e376 3556d08e17f9 40a43921ef42 4ac01a732552 5c6e92fd8d71 64caed18236a 66fd9d6b1452 737de0fac722 89e89836cae7 8ddebaa7e856 a160b98fb922 b5668108487f b64e2537d702 c26267b3aba6 c40c2d3edb51 d23464c0ac60 e8e7158bbc73 ef20fdc14e8b fb8f35d025ab
Depended on by
nothing yet
In bookmarks
main
Edits
88 atoms, 0 tree operations, 17 files

Browse the tree at this change

modified CHANGELOG.md +8 −0
…
1 unchanged lines
2 2
3 3
OxygenHub runs at https://oxygenhub.io; each deploy's build is on the About page and at `/api/v1/version`.
4 4
5 +
## Unreleased
6 +
7 +
- **The New repository page in sections (#371):** Repository, Visibility, Starting files, Wiki, Project board, OxygenFlow and Storage, each with a heading and a line on what it does, and the choices as cards side by side that stack on a phone.
8 +
- **OxygenFlow with a new repository (#371):** on the site's runners (site administrators) or the owner's own (when the owner has one), set as it is made; left alone, the site's default applies.
9 +
- **Start the wiki (#371):** a Home page with the repository's name and description, written as you with the hub signing as your delegate.
10 +
- **Default bookmark (#371):** chosen on the New repository page, else the owner's default, else the site's, else `main`. A user sets theirs under Settings › New repositories, an organisation under its settings, and a site administrator under Administration › New repositories.
11 +
- The API's create takes `flow` (`on`, `own`), `wiki` and `default_bookmark`; `oh repo create … --flow on|own --wiki --bookmark NAME`.
12 +
5 13
## 0.9.24 (2026-11-04)
6 14
7 15
- **A fresh New repository page (#360):** owner and name side by side with a live check that the name is free and valid and the address it will have; visibility as three described choices; **Start with** a README, a licence (MIT, BSD 2-Clause or ISC, with what each allows) and an `.oxyignore` for a language, committed as you with the hub signing as your delegate (it says what to do first when you have not delegated, before anything is made); and a **project board** with it: a new one (Backlog, Doing, Done, on by default for an organisation) or one of the owner's, which takes the repository's issues and proposals as they open. Storage moves under its own heading. The API's create takes the same (`project: {"new": TITLE} | {"number": N}`, `readme`, `licence`, `ignore`), and `oh repo create OWNER/NAME [--project new:TITLE|N] [--readme] [--licence MIT] [--ignore Rust]`.
…
474 unchanged lines
modified assets/hub.js +30 −0
…
1114 unchanged lines
1115 1115
      if (exRadio) exRadio.parentElement.hidden = !first;
1116 1116
    }
1117 1117
  }
1118 +
  // The owner's default bookmark (#371): shown, and taken when left as it is.
1119 +
  var bm = form.querySelector("[data-default-bookmark]");
1120 +
  var bmFrom = form.querySelector("[data-bookmark-hint]");
1121 +
  var bmTouched = false;
1122 +
  if (bm) bm.addEventListener("input", function () { bmTouched = true; });
1123 +
  function bookmark() {
1124 +
    var o = owner.selectedOptions[0];
1125 +
    if (!bm || !o) return;
1126 +
    var b = o.getAttribute("data-bookmark") || "main";
1127 +
    if (!bmTouched) bm.value = b;
1128 +
    bm.placeholder = b;
1129 +
    if (bmFrom) bmFrom.textContent = b + " is " + (o.getAttribute("data-bookmark-from") || "the default") + ". Change it in Settings.";
1130 +
  }
1131 +
  // On its owner's runners only when the owner has one (#371).
1132 +
  var runners = (owner.getAttribute("data-runners") || "").split(" ").filter(Boolean);
1133 +
  var own = form.querySelector("[data-flow-own]");
1134 +
  function flow() {
1135 +
    if (!own) return;
1136 +
    var has = runners.indexOf(owner.value) >= 0;
1137 +
    var r = own.querySelector("input");
1138 +
    r.disabled = !has;
1139 +
    if (!has && r.checked) form.querySelector("input[name=flow][value='']").checked = true;
1140 +
    own.classList.toggle("off", !has);
1141 +
    var note = own.querySelector("[data-flow-own-note]");
1142 +
    if (note) note.textContent = has ? "Only " + owner.value + "'s own runners take its jobs." : owner.value + " has no runners of its own yet.";
1143 +
  }
1118 1144
  owner.addEventListener("change", function () {
1145 +
    bookmark();
1146 +
    flow();
1119 1147
    // An organisation's repository has a board by default.
1120 1148
    var neu = form.querySelector("[data-board-new]");
1121 1149
    if (neu && orgs.indexOf(owner.value) >= 0) neu.checked = true;
…
3 unchanged lines
1125 1153
  form.querySelectorAll("input[name=board]").forEach(function (r) { r.addEventListener("change", boards); });
1126 1154
  if (orgs.indexOf(owner.value) >= 0) { var n0 = form.querySelector("[data-board-new]"); if (n0) n0.checked = true; }
1127 1155
  boards();
1156 +
  bookmark();
1157 +
  flow();
1128 1158
})();
modified crates/hub-api/src/lib.rs +12 −0
…
1000 unchanged lines
1001 1001
    pub licence: Option<String>,
1002 1002
    #[serde(default, skip_serializing_if = "Option::is_none")]
1003 1003
    pub ignore: Option<String>,
1004 +
    /// OxygenFlow with it (#371): `on` (the site's runners; site
1005 +
    /// administrators only) or `own` (the owner's runners, when it has
1006 +
    /// one). Left out, the site's default applies.
1007 +
    #[serde(default, skip_serializing_if = "Option::is_none")]
1008 +
    pub flow: Option<String>,
1009 +
    /// Start its wiki with a Home page giving its name and description.
1010 +
    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
1011 +
    pub wiki: bool,
1012 +
    /// Its default bookmark. Left out, the owner's default, else the
1013 +
    /// site's, else `main`.
1014 +
    #[serde(default, skip_serializing_if = "Option::is_none")]
1015 +
    pub default_bookmark: Option<String>,
1004 1016
}
1005 1017
1006 1018
/// A new repository's board: a new one, or the owner's existing one.
…
1070 unchanged lines
modified crates/hub/src/api/mod.rs +16 −1
…
1086 unchanged lines
1087 1087
        },
1088 1088
    };
1089 1089
    crate::repo_setup::check_board(&app, me, &name.owner, &board).await.map_err(|m| ApiError(StatusCode::UNPROCESSABLE_ENTITY, m))?;
1090 +
    // OxygenFlow and the wiki with it (#371).
1091 +
    let bookmark = body.default_bookmark.clone().unwrap_or_default();
1092 +
    if !bookmark.trim().is_empty() {
1093 +
        crate::repo_setup::check_bookmark(bookmark.trim()).map_err(|m| ApiError(StatusCode::BAD_REQUEST, m))?;
1094 +
    }
1095 +
    let flow = crate::repo_setup::Flow::parse(body.flow.as_deref().unwrap_or("")).map_err(|m| ApiError(StatusCode::BAD_REQUEST, m))?;
1096 +
    crate::repo_setup::check_flow(&app, me, &name.owner, flow).await.map_err(|m| ApiError(StatusCode::FORBIDDEN, m))?;
1090 1097
    let start =
1091 1098
        Start { readme: body.readme, licence: body.licence.clone().unwrap_or_default(), ignore: body.ignore.clone().unwrap_or_default() };
1092 1099
    let holder = if me.display_name.trim().is_empty() { me.username.clone() } else { me.display_name.clone() };
1093 1100
    let files = start
1094 1101
        .files(&name, &body.description, &holder, time::OffsetDateTime::now_utc().year())
1095 1102
        .map_err(|m| ApiError(StatusCode::BAD_REQUEST, m))?;
1096 -
    if !files.is_empty() {
1103 +
    if !files.is_empty() || body.wiki {
1097 1104
        crate::web::edit::signer_for(&app, me.id).await.map_err(|m| ApiError(StatusCode::CONFLICT, m))?;
1098 1105
    }
1099 1106
    app.repos.create_async(&name, None).await?;
1100 1107
    let row = db::repos::create(&app.db, &name.owner, &name.name, &body.description, body.visibility).await?;
1108 +
    let row = crate::repo_setup::set_default_bookmark(&app, row, &bookmark).await?;
1101 1109
    db::accounts::audit(&app.db, Some(me), "repo.created", &name.to_string(), "api").await.ok();
1102 1110
    crate::repo_setup::link_board(&app, &row, &board, body.visibility == Visibility::Public).await?;
1111 +
    crate::repo_setup::set_flow(&app, &row, me, flow).await?;
1103 1112
    crate::repo_setup::commit_start(&app, &row, me, files).await.map_err(|m| ApiError(StatusCode::INTERNAL_SERVER_ERROR, m))?;
1113 +
    if body.wiki {
1114 +
        crate::repo_setup::start_wiki(&app, &row, me, &body.description)
1115 +
            .await
1116 +
            .map_err(|m| ApiError(StatusCode::INTERNAL_SERVER_ERROR, m))?;
1117 +
    }
1118 +
    let row = db::repos::get_by_id(&app.db, row.id).await?.unwrap_or(row);
1104 1119
    Ok((StatusCode::CREATED, Json(repository(&app, &row))))
1105 1120
}
1106 1121
…
2433 unchanged lines
modified crates/hub/src/repo_setup.rs +185 −1
…
2 unchanged lines
3 3
//! files it starts with (a README, a licence, an `.oxyignore` for a
4 4
//! language), committed as its creator the way the starter's are (#347),
5 5
//! and a project board made or chosen with it, which then takes its issues
6 -
//! and proposals as they open.
6 +
//! and proposals as they open. OxygenFlow switched on and the wiki
7 +
//! started with a Home page come with it too (#371).
7 8
8 9
use crate::auth::roles;
9 10
use crate::db::{self, projects as pdb};
…
79 unchanged lines
89 90
    Ok(Some(p.number))
90 91
}
91 92
93 +
/// Where a new repository's default bookmark comes from (#371): the
94 +
/// owner's own choice (a user's or an organisation's), else the site's,
95 +
/// else `main`. Kept in the settings table, so nothing else changes.
96 +
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
97 +
pub enum BookmarkScope {
98 +
    Site,
99 +
    Account(i64),
100 +
    Org(i64),
101 +
}
102 +
103 +
/// The bookmark a repository has when nothing else is chosen.
104 +
pub const MAIN: &str = "main";
105 +
106 +
impl BookmarkScope {
107 +
    fn key(self) -> String {
108 +
        match self {
109 +
            BookmarkScope::Site => "default_bookmark".into(),
110 +
            BookmarkScope::Account(id) => format!("default_bookmark.account.{id}"),
111 +
            BookmarkScope::Org(id) => format!("default_bookmark.org.{id}"),
112 +
        }
113 +
    }
114 +
}
115 +
116 +
/// The default bookmark set at `scope`, if one is.
117 +
pub async fn bookmark_setting(pool: &db::Pool, scope: BookmarkScope) -> sqlx::Result<Option<String>> {
118 +
    Ok(db::accounts::setting(pool, &scope.key()).await?.filter(|v| !v.is_empty()))
119 +
}
120 +
121 +
/// Set (or, empty, clear) the default bookmark at `scope`.
122 +
pub async fn set_bookmark_setting(pool: &db::Pool, scope: BookmarkScope, value: &str) -> Result<(), String> {
123 +
    let v = value.trim();
124 +
    if !v.is_empty() {
125 +
        check_bookmark(v)?;
126 +
    }
127 +
    db::accounts::set_setting(pool, &scope.key(), v).await.map_err(|e| e.to_string())
128 +
}
129 +
130 +
/// A bookmark name a repository may default to.
131 +
pub fn check_bookmark(name: &str) -> Result<(), String> {
132 +
    if crate::repos::names::valid_segment(name) {
133 +
        Ok(())
134 +
    } else {
135 +
        Err(format!(
136 +
            "{name:?} is not a bookmark name: letters, digits, dots, dashes and underscores, up to 64, starting with a letter or digit."
137 +
        ))
138 +
    }
139 +
}
140 +
141 +
/// The default bookmark a new repository under `owner` takes, and where
142 +
/// it comes from (for the page: "develop, from doomsdayone's settings").
143 +
pub async fn default_bookmark_for(app: &Shared, owner: &str) -> (String, String) {
144 +
    let own = match db::accounts::org_by_name(&app.db, owner).await.ok().flatten() {
145 +
        Some(org) => Some(BookmarkScope::Org(org.id)),
146 +
        None => db::accounts::account_by_name(&app.db, owner).await.ok().flatten().map(|a| BookmarkScope::Account(a.id)),
147 +
    };
148 +
    if let Some(scope) = own {
149 +
        if let Ok(Some(b)) = bookmark_setting(&app.db, scope).await {
150 +
            return (b, format!("{owner}'s default"));
151 +
        }
152 +
    }
153 +
    match bookmark_setting(&app.db, BookmarkScope::Site).await {
154 +
        Ok(Some(b)) => (b, "this site's default".into()),
155 +
        _ => (MAIN.into(), "the usual default".into()),
156 +
    }
157 +
}
158 +
159 +
/// The new repository's default bookmark: the one asked for, or its
160 +
/// owner's, the site's or `main`; set on its row when it is not `main`.
161 +
pub async fn set_default_bookmark(app: &Shared, row: db::repos::RepoRow, asked: &str) -> sqlx::Result<db::repos::RepoRow> {
162 +
    let asked = asked.trim();
163 +
    let b = if asked.is_empty() { default_bookmark_for(app, &row.owner).await.0 } else { asked.to_string() };
164 +
    if b == row.default_bookmark {
165 +
        return Ok(row);
166 +
    }
167 +
    db::repos::set_default_bookmark(&app.db, row.id, &b).await?;
168 +
    Ok(db::repos::get_by_id(&app.db, row.id).await?.unwrap_or(row))
169 +
}
170 +
171 +
/// OxygenFlow for a new repository (#371): left to the site's default,
172 +
/// on the site's runners (a site administrator's choice), or on the
173 +
/// owner's own runners.
174 +
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
175 +
pub enum Flow {
176 +
    Default,
177 +
    On,
178 +
    Own,
179 +
}
180 +
181 +
impl Flow {
182 +
    pub fn parse(choice: &str) -> Result<Flow, String> {
183 +
        match choice.trim() {
184 +
            "" | "default" | "off" => Ok(Flow::Default),
185 +
            "on" => Ok(Flow::On),
186 +
            "own" => Ok(Flow::Own),
187 +
            other => Err(format!("OxygenFlow is on, own or left to the default, not {other}.")),
188 +
        }
189 +
    }
190 +
191 +
    /// The repository's `flow` setting, as Settings › Pipelines writes it.
192 +
    pub fn setting(self) -> &'static str {
193 +
        match self {
194 +
            Flow::Default => "",
195 +
            Flow::On => "on",
196 +
            Flow::Own => "own",
197 +
        }
198 +
    }
199 +
}
200 +
201 +
/// Whether `owner` has runners of its own (not the site's), which
202 +
/// [`Flow::Own`] needs before its repository exists.
203 +
pub async fn owner_has_runner(app: &Shared, owner: &str) -> sqlx::Result<bool> {
204 +
    Ok(db::runners::serving_owner(&app.db, owner).await?.iter().any(|r| !r.scope().is_site()))
205 +
}
206 +
207 +
/// Checked before the repository is made, as Settings › Pipelines checks.
208 +
pub async fn check_flow(app: &Shared, account: &db::accounts::Account, owner: &str, flow: Flow) -> Result<(), String> {
209 +
    match flow {
210 +
        Flow::Default => Ok(()),
211 +
        Flow::On if account.is_admin => Ok(()),
212 +
        Flow::On => Err("Only site administrators let the site's runners take a repository's jobs.".into()),
213 +
        Flow::Own => match owner_has_runner(app, owner).await.map_err(|e| e.to_string())? {
214 +
            true => Ok(()),
215 +
            false => {
216 +
                Err(format!("{owner} has no runners of its own yet: register one, then switch OxygenFlow on in Settings › Pipelines."))
217 +
            }
218 +
        },
219 +
    }
220 +
}
221 +
222 +
/// Switch OxygenFlow on for the new repository, as chosen.
223 +
pub async fn set_flow(app: &Shared, row: &db::repos::RepoRow, by: &db::accounts::Account, flow: Flow) -> sqlx::Result<()> {
224 +
    if flow == Flow::Default {
225 +
        return Ok(());
226 +
    }
227 +
    db::repos::set_flow(&app.db, row.id, flow.setting()).await?;
228 +
    db::accounts::audit(&app.db, Some(by), "repo.flow", &format!("{}/{}", row.owner, row.name), flow.setting()).await.ok();
229 +
    Ok(())
230 +
}
231 +
232 +
/// A new wiki's Home page: the repository's name and description.
233 +
pub fn wiki_home(name: &RepoName, description: &str) -> String {
234 +
    let about = description.trim();
235 +
    let about = if about.is_empty() { String::new() } else { format!("{about}\n\n") };
236 +
    format!("# {}\n\n{about}This is the wiki's home page. Edit it, or add pages beside it.\n", name.name)
237 +
}
238 +
239 +
/// Start the repository's wiki with its Home page, written as `by` and
240 +
/// signed as their delegate, as a wiki edit on the site is.
241 +
pub async fn start_wiki(app: &Shared, row: &db::repos::RepoRow, by: &db::accounts::Account, description: &str) -> Result<(), String> {
242 +
    let name = RepoName::new(&row.owner, &row.name).ok_or("bad repository name")?;
243 +
    let signer = crate::web::edit::signer_for(app, by.id).await?;
244 +
    let edit = FileEdit { path: crate::wiki::path_of(crate::wiki::HOME), content: Some(wiki_home(&name, description).into_bytes()) };
245 +
    crate::wiki::commit(app, &name, vec![edit], "Start the wiki".into(), signer).await.map_err(|e| e.to_string())?;
246 +
    db::accounts::audit(&app.db, Some(by), "wiki.started", &name.to_string(), "Home").await.ok();
247 +
    Ok(())
248 +
}
249 +
92 250
/// The licences offered, with what each allows, for the page.
93 251
pub const LICENCES: &[(&str, &str)] = &[
94 252
    ("MIT", "use, change and share it freely, keeping the notice"),
…
158 unchanged lines
253 411
        }
254 412
        assert!(Start { licence: "GPL".into(), ..Default::default() }.files(&name, "", "X", 2026).is_err());
255 413
        assert!(Start::default().is_empty());
414 +
    }
415 +
416 +
    #[test]
417 +
    fn flow_choices_and_the_wiki_home() {
418 +
        assert_eq!(Flow::parse("").unwrap(), Flow::Default);
419 +
        assert_eq!(Flow::parse("off").unwrap(), Flow::Default);
420 +
        assert_eq!(Flow::parse("on").unwrap().setting(), "on");
421 +
        assert_eq!(Flow::parse("own").unwrap().setting(), "own");
422 +
        assert!(Flow::parse("always").is_err());
423 +
        let name = RepoName::new("andy", "square").unwrap();
424 +
        assert_eq!(
425 +
            wiki_home(&name, " A platform game. "),
426 +
            "# square\n\nA platform game.\n\nThis is the wiki's home page. Edit it, or add pages beside it.\n"
427 +
        );
428 +
        assert!(wiki_home(&name, "").starts_with("# square\n\nThis is"));
429 +
    }
430 +
431 +
    #[test]
432 +
    fn bookmark_defaults_are_kept_apart() {
433 +
        assert_eq!(BookmarkScope::Site.key(), "default_bookmark");
434 +
        assert_eq!(BookmarkScope::Account(7).key(), "default_bookmark.account.7");
435 +
        assert_eq!(BookmarkScope::Org(7).key(), "default_bookmark.org.7");
436 +
        assert!(check_bookmark("develop").is_ok());
437 +
        assert!(check_bookmark("release-1.0").is_ok());
438 +
        assert!(check_bookmark("feature/x").is_err());
439 +
        assert!(check_bookmark("-x").is_err());
256 440
    }
257 441
}
modified crates/hub/src/web/admin.rs +40 −1
…
31 unchanged lines
32 32
    Diffusion,
33 33
    Storage,
34 34
    Backups,
35 +
    NewRepositories,
35 36
    Audit,
36 37
}
37 38
…
16 unchanged lines
54 55
        ],
55 56
    ),
56 57
    ("Integrations", &[AdminSection::Assistants, AdminSection::Diffusion]),
57 -
    ("Data", &[AdminSection::Storage, AdminSection::Backups]),
58 +
    ("Data", &[AdminSection::NewRepositories, AdminSection::Storage, AdminSection::Backups]),
58 59
    ("Audit", &[AdminSection::Audit]),
59 60
];
60 61
…
24 unchanged lines
85 86
            AdminSection::Diffusion => "/admin/diffusion",
86 87
            AdminSection::Storage => "/admin/storage",
87 88
            AdminSection::Backups => "/admin/backups",
89 +
            AdminSection::NewRepositories => "/admin/repositories",
88 90
            AdminSection::Audit => "/admin/audit",
89 91
        }
90 92
    }
…
20 unchanged lines
111 113
            AdminSection::Diffusion => "diffusion",
112 114
            AdminSection::Storage => "storage",
113 115
            AdminSection::Backups => "backups",
116 +
            AdminSection::NewRepositories => "repositories",
114 117
            AdminSection::Audit => "audit",
115 118
        }
116 119
    }
…
18 unchanged lines
135 138
            AdminSection::Diffusion => "Diffusion",
136 139
            AdminSection::Storage => "Storage & maintenance",
137 140
            AdminSection::Backups => "Backups",
141 +
            AdminSection::NewRepositories => "New repositories",
138 142
            AdminSection::Audit => "Audit log",
139 143
        }
140 144
    }
…
14 unchanged lines
155 159
    orgs: Vec<accounts::Organisation>,
156 160
    mode: String,
157 161
    invitations: Vec<(String, Option<i64>, i64)>,
162 +
    /// New repositories' default bookmark across the site (#371); empty, `main`.
163 +
    site_bookmark: String,
158 164
    audit: Vec<accounts::AuditRow>,
159 165
    repos: usize,
160 166
    notice: Option<String>,
…
56 unchanged lines
217 223
    let orgs = accounts::orgs(&app.db).await.map_err(e)?;
218 224
    let mode = super::session::registration_mode(app).await;
219 225
    let invitations = accounts::invitations(&app.db).await.map_err(e)?;
226 +
    let site_bookmark =
227 +
        crate::repo_setup::bookmark_setting(&app.db, crate::repo_setup::BookmarkScope::Site).await.map_err(e)?.unwrap_or_default();
220 228
    let mut audit = accounts::audit_page(&app.db, audit_before, AUDIT_PAGE + 1).await.map_err(e)?;
221 229
    let audit_older = if audit.len() as i64 > AUDIT_PAGE {
222 230
        audit.truncate(AUDIT_PAGE as usize);
…
28 unchanged lines
251 259
        accounts: accounts_list,
252 260
        orgs,
253 261
        mode,
262 +
        site_bookmark,
254 263
        invitations,
255 264
        audit,
256 265
        repos,
…
176 unchanged lines
433 442
    admin_page(&app, shell, AdminSection::Registration, Some(format!("Registration is {}.", f.mode))).await
434 443
}
435 444
445 +
#[derive(Deserialize)]
446 +
pub struct BookmarkForm {
447 +
    #[serde(rename = "_csrf")]
448 +
    csrf_token: String,
449 +
    #[serde(default)]
450 +
    default_bookmark: String,
451 +
}
452 +
453 +
/// `POST /admin/repositories` (oxygenhub#371): the default bookmark of new
454 +
/// repositories whose owner has not chosen one; empty, `main`.
455 +
pub async fn set_bookmark(State(app): State<Shared>, viewer: Viewer, Form(f): Form<BookmarkForm>) -> Result<Page<AdminPage>, Error> {
456 +
    let shell = Shell::new(&app, "/admin", &viewer);
457 +
    let me = require_admin(&viewer, &shell)?;
458 +
    csrf(&viewer, &f.csrf_token, &shell)?;
459 +
    let scope = crate::repo_setup::BookmarkScope::Site;
460 +
    if let Err(m) = crate::repo_setup::set_bookmark_setting(&app.db, scope, &f.default_bookmark).await {
461 +
        return Err(Error::bad(shell, &m));
462 +
    }
463 +
    let b = f.default_bookmark.trim();
464 +
    accounts::audit(&app.db, Some(&me), "admin.default_bookmark", b, "").await.ok();
465 +
    let b = if b.is_empty() { crate::repo_setup::MAIN } else { b };
466 +
    admin_page(
467 +
        &app,
468 +
        shell,
469 +
        AdminSection::NewRepositories,
470 +
        Some(format!("New repositories start on {b} unless their owner chooses another.")),
471 +
    )
472 +
    .await
473 +
}
474 +
436 475
pub async fn invite(State(app): State<Shared>, viewer: Viewer, Form(f): Form<super::session::CsrfForm>) -> Result<Page<AdminPage>, Error> {
437 476
    let shell = Shell::new(&app, "/admin", &viewer);
438 477
    let me = require_admin(&viewer, &shell)?;
…
289 unchanged lines
modified crates/hub/src/web/mod.rs +2 −0
…
117 unchanged lines
118 118
        .route("/settings", get(settings::profile).post(settings::update_profile))
119 119
        .route("/settings/password", post(settings::change_password))
120 120
        .route("/settings/appearance", post(settings::appearance))
121 +
        .route("/settings/repositories", post(settings::repositories))
121 122
        .route("/settings/security", get(settings::security))
122 123
        .route("/settings/security/totp/begin", post(settings::totp_begin))
123 124
        .route("/settings/security/totp/enable", post(settings::totp_enable))
…
47 unchanged lines
171 172
        .route("/admin/organisations", get(admin::section))
172 173
        .route("/admin/audit", get(admin::section))
173 174
        .route("/admin/registration", get(admin::section).post(admin::set_mode))
175 +
        .route("/admin/repositories", get(admin::section).post(admin::set_bookmark))
174 176
        .route("/admin/invite", post(admin::invite))
175 177
        .route("/admin/account", post(admin::account))
176 178
        .route("/admin/storage", get(admin::storage).post(admin::maintain))
…
802 unchanged lines
modified crates/hub/src/web/orgs.rs +23 −0
…
79 unchanged lines
80 80
    secrets: super::secrets::Panel,
81 81
    /// Where its new repositories may live (#96).
82 82
    policy: crate::storage::Policy,
83 +
    /// Its new repositories' default bookmark (#371), and the site's.
84 +
    default_bookmark: String,
85 +
    site_bookmark: String,
83 86
}
84 87
85 88
async fn org_page(
…
28 unchanged lines
114 117
    let variables = super::variables::panel(app, &org_assistants(&org)).await.map_err(|e| Error::from_db(shell.clone(), e))?;
115 118
    let secrets = super::secrets::panel(app, &org_assistants(&org)).await.map_err(|e| Error::from_db(shell.clone(), e))?;
116 119
    let policy = crate::storage::policy(&app.db, org.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
120 +
    let default_bookmark = crate::repo_setup::bookmark_setting(&app.db, crate::repo_setup::BookmarkScope::Org(org.id))
121 +
        .await
122 +
        .ok()
123 +
        .flatten()
124 +
        .unwrap_or_default();
125 +
    let site_bookmark = crate::repo_setup::bookmark_setting(&app.db, crate::repo_setup::BookmarkScope::Site)
126 +
        .await
127 +
        .ok()
128 +
        .flatten()
129 +
        .unwrap_or_else(|| crate::repo_setup::MAIN.into());
117 130
    Ok(Page(OrgSettingsPage {
131 +
        default_bookmark,
132 +
        site_bookmark,
118 133
        policy,
119 134
        shell,
120 135
        org,
…
41 unchanged lines
162 177
    display_name: String,
163 178
    description: String,
164 179
    default_role: String,
180 +
    /// Its new repositories' default bookmark (#371); empty, the site's.
181 +
    #[serde(default)]
182 +
    default_bookmark: String,
165 183
}
166 184
167 185
pub async fn update(
…
6 unchanged lines
174 192
    let (me, org) = owned_org(&app, &shell, &viewer, &name).await?;
175 193
    csrf(&viewer, &f.csrf_token, &shell)?;
176 194
    let role = if f.default_role == "none" || roles::Role::parse(&f.default_role).is_some() { f.default_role.as_str() } else { "read" };
195 +
    if let Err(m) =
196 +
        crate::repo_setup::set_bookmark_setting(&app.db, crate::repo_setup::BookmarkScope::Org(org.id), &f.default_bookmark).await
197 +
    {
198 +
        return org_page(&app, shell, org, None, Some(m)).await;
199 +
    }
177 200
    accounts::set_org(&app.db, org.id, f.display_name.trim(), f.description.trim(), role)
178 201
        .await
179 202
        .map_err(|e| Error::from_db(shell.clone(), e))?;
…
429 unchanged lines
modified crates/hub/src/web/repo_settings.rs +59 −2
…
26 unchanged lines
27 27
    licences: &'static [(&'static str, &'static str)],
28 28
    ignores: &'static [&'static str],
29 29
    base_url: String,
30 +
    /// Whether the viewer may put a repository on the site's runners (#371).
31 +
    admin: bool,
32 +
    /// Owners with runners of their own, for "on its owner's runners".
33 +
    runner_owners: Vec<String>,
34 +
    /// Each owner's default bookmark and where it comes from (#371).
35 +
    bookmarks: Vec<(String, String, String)>,
30 36
}
31 37
32 38
/// The New repository page's choices for `me` (#360).
…
1 unchanged lines
34 40
    let owners = owners_for(app, me).await;
35 41
    let mut boards = Vec::new();
36 42
    let mut orgs = Vec::new();
43 +
    let mut runner_owners = Vec::new();
44 +
    let mut bookmarks = Vec::new();
37 45
    for o in &owners {
46 +
        let (b, from) = crate::repo_setup::default_bookmark_for(app, o).await;
47 +
        bookmarks.push((o.clone(), b, from));
48 +
        if crate::repo_setup::owner_has_runner(app, o).await.unwrap_or(false) {
49 +
            runner_owners.push(o.clone());
50 +
        }
38 51
        if accounts::org_by_name(&app.db, o).await.ok().flatten().is_some() {
39 52
            orgs.push(o.clone());
40 53
        }
…
13 unchanged lines
54 67
        licences: crate::repo_setup::LICENCES,
55 68
        ignores: crate::repo_setup::IGNORES,
56 69
        base_url: app.config.base_url().trim_end_matches('/').to_string(),
70 +
        admin: me.is_admin,
71 +
        runner_owners,
72 +
        bookmarks,
57 73
    }
58 74
}
59 75
…
70 unchanged lines
130 146
    /// The existing board's number, with `board=existing`.
131 147
    #[serde(default)]
132 148
    board_n: String,
149 +
    /// OxygenFlow (#371): `on`, `own`, or the site default.
150 +
    #[serde(default)]
151 +
    flow: String,
152 +
    /// Start the wiki with a Home page (#371).
153 +
    #[serde(default)]
154 +
    wiki: Option<String>,
155 +
    /// Its default bookmark; empty, the owner's or the site's default (#371).
156 +
    #[serde(default)]
157 +
    default_bookmark: String,
133 158
}
134 159
135 160
/// A bucket and the credentials for it, as the New repository form and
…
52 unchanged lines
188 213
        Err(m) => return Ok(fail(&m)),
189 214
    };
190 215
    if let Err(m) = crate::repo_setup::check_board(&app, &me.account, &name.owner, &board).await {
216 +
        return Ok(fail(&m));
217 +
    }
218 +
    let flow = match crate::repo_setup::Flow::parse(&f.flow) {
219 +
        Ok(flow) => flow,
220 +
        Err(m) => return Ok(fail(&m)),
221 +
    };
222 +
    if let Err(m) = crate::repo_setup::check_flow(&app, &me.account, &name.owner, flow).await {
191 223
        return Ok(fail(&m));
224 +
    }
225 +
    let wiki = f.wiki.is_some();
226 +
    if !f.default_bookmark.trim().is_empty() {
227 +
        if let Err(m) = crate::repo_setup::check_bookmark(f.default_bookmark.trim()) {
228 +
            return Ok(fail(&m));
229 +
        }
192 230
    }
193 231
    let start =
194 232
        crate::repo_setup::Start { readme: f.readme.is_some(), licence: f.licence.trim().to_string(), ignore: f.ignore.trim().to_string() };
…
6 unchanged lines
201 239
    if !files.is_empty() && f.storage == "bucket" {
202 240
        return Ok(fail("A repository kept in your own bucket starts empty: push its first files to it."));
203 241
    }
204 -
    if !files.is_empty() {
242 +
    if !files.is_empty() || wiki {
205 243
        if let Err(m) = crate::web::edit::signer_for(&app, me.account.id).await {
206 -
            return Ok(fail(&format!("The hub cannot write the starting files for you yet. {m}")));
244 +
            return Ok(fail(&format!("The hub cannot write the starting files or the wiki for you yet. {m}")));
207 245
        }
208 246
    }
209 247
    // An organisation's storage policy (oxygenhub#96).
…
19 unchanged lines
229 267
        let row = db::repos::create(&app.db, &name.owner, &name.name, f.description.trim(), visibility)
230 268
            .await
231 269
            .map_err(|e| Error::from_db(shell.clone(), e))?;
270 +
        let row =
271 +
            crate::repo_setup::set_default_bookmark(&app, row, &f.default_bookmark).await.map_err(|e| Error::from_db(shell.clone(), e))?;
232 272
        if let Err(e) = crate::storage::create(&app, &name, row.id, url, credentials).await {
233 273
            db::repos::delete(&app.db, row.id).await.ok();
234 274
            return Ok(fail(&format!("The bucket did not pass the check, so nothing was created: {e}")));
…
10 unchanged lines
245 285
        crate::repo_setup::link_board(&app, &row, &board, visibility == Visibility::Public)
246 286
            .await
247 287
            .map_err(|e| Error::from_db(shell.clone(), e))?;
288 +
        crate::repo_setup::set_flow(&app, &row, &me.account, flow).await.map_err(|e| Error::from_db(shell.clone(), e))?;
289 +
        if wiki {
290 +
            if let Err(e) = crate::repo_setup::start_wiki(&app, &row, &me.account, &f.description).await {
291 +
                tracing::warn!("wiki for {name}: {e}");
292 +
            }
293 +
        }
248 294
        return Ok(Redirect::to(&name.url()).into_response());
249 295
    }
250 296
    app.repos.create_async(&name, None).await.map_err(|e| Error::from_repo(shell.clone(), e))?;
251 297
    let row = db::repos::create(&app.db, &name.owner, &name.name, f.description.trim(), visibility)
252 298
        .await
253 299
        .map_err(|e| Error::from_db(shell.clone(), e))?;
300 +
    let row =
301 +
        crate::repo_setup::set_default_bookmark(&app, row, &f.default_bookmark).await.map_err(|e| Error::from_db(shell.clone(), e))?;
254 302
    accounts::audit(&app.db, Some(&me.account), "repo.created", &name.to_string(), visibility.as_str()).await.ok();
255 303
    crate::repo_setup::link_board(&app, &row, &board, visibility == Visibility::Public)
256 304
        .await
257 305
        .map_err(|e| Error::from_db(shell.clone(), e))?;
306 +
    crate::repo_setup::set_flow(&app, &row, &me.account, flow).await.map_err(|e| Error::from_db(shell.clone(), e))?;
258 307
    if let Err(e) = crate::repo_setup::commit_start(&app, &row, &me.account, files).await {
259 308
        tracing::warn!("starting files for {name}: {e}");
309 +
    }
310 +
    if wiki {
311 +
        if let Err(e) = crate::repo_setup::start_wiki(&app, &row, &me.account, &f.description).await {
312 +
            tracing::warn!("wiki for {name}: {e}");
313 +
        }
260 314
    }
261 315
    Ok(Redirect::to(&name.url()).into_response())
262 316
}
…
8 unchanged lines
271 325
        licences: p.licences,
272 326
        ignores: p.ignores,
273 327
        base_url: p.base_url.clone(),
328 +
        admin: p.admin,
329 +
        runner_owners: p.runner_owners.clone(),
330 +
        bookmarks: p.bookmarks.clone(),
274 331
    }
275 332
}
276 333
…
1503 unchanged lines
modified crates/hub/src/web/settings.rs +58 −11
…
50 unchanged lines
51 51
    backdrops: &'static [(&'static str, &'static str)],
52 52
    /// Whether the hub sends mail, for the "send a new link" button.
53 53
    mail: bool,
54 +
    /// The default bookmark of the repositories they make (#371), and the
55 +
    /// one they get when that is empty.
56 +
    default_bookmark: String,
57 +
    site_bookmark: String,
54 58
}
55 59
56 60
/// The themes (hub spec §6.1): the cookie value and its label.
…
26 unchanged lines
83 87
            backdrop: backdrop_of(headers),
84 88
            backdrops: BACKDROPS,
85 89
            mail: false,
90 +
            default_bookmark: String::new(),
91 +
            site_bookmark: crate::repo_setup::MAIN.into(),
86 92
        }
87 93
    }
88 94
89 -
    fn with_mail(mut self, app: &Shared) -> Self {
95 +
    /// What the page reads from the hub: whether it sends mail, and the
96 +
    /// default bookmarks (#371).
97 +
    async fn with_hub(mut self, app: &Shared) -> Self {
98 +
        use crate::repo_setup::{bookmark_setting, BookmarkScope};
90 99
        self.mail = crate::mail::enabled(app);
100 +
        self.default_bookmark = bookmark_setting(&app.db, BookmarkScope::Account(self.me.id)).await.ok().flatten().unwrap_or_default();
101 +
        if let Ok(Some(b)) = bookmark_setting(&app.db, BookmarkScope::Site).await {
102 +
            self.site_bookmark = b;
103 +
        }
91 104
        self
92 105
    }
93 106
…
11 unchanged lines
105 118
pub async fn profile(State(app): State<Shared>, viewer: Viewer, headers: HeaderMap) -> Result<Page<SettingsPage>, Error> {
106 119
    let shell = Shell::new(&app, "/settings", &viewer);
107 120
    let me = require(&viewer, &shell)?;
108 -
    Ok(Page(SettingsPage::new(shell, me.account, &headers).with_mail(&app)))
121 +
    Ok(Page(SettingsPage::new(shell, me.account, &headers).with_hub(&app).await))
109 122
}
110 123
111 124
/// `POST /settings/avatar`: upload or remove the profile picture.
…
12 unchanged lines
124 137
        accounts::audit(&app.db, Some(&me.account), "profile.picture", &me.account.username, "").await.ok();
125 138
    }
126 139
    let fresh = accounts::account_by_id(&app.db, me.account.id).await.map_err(|e| Error::from_db(shell.clone(), e))?.unwrap_or(me.account);
127 -
    let page = SettingsPage::new(shell, fresh, &headers).with_mail(&app);
140 +
    let page = SettingsPage::new(shell, fresh, &headers).with_hub(&app).await;
128 141
    Ok(Page(match result {
129 142
        Ok(m) => page.notice(m),
130 143
        Err(e) => page.error(e),
…
10 unchanged lines
141 154
    let shell = Shell::new(&app, "/settings", &viewer);
142 155
    let me = require(&viewer, &shell)?;
143 156
    csrf(&viewer, &f.csrf, &shell)?;
144 -
    let page = SettingsPage::new(shell, me.account.clone(), &headers).with_mail(&app);
157 +
    let page = SettingsPage::new(shell, me.account.clone(), &headers).with_hub(&app).await;
145 158
    Ok(Page(match super::recovery::send_verification(&app, &me.account).await {
146 159
        Ok(()) if me.account.email_verified => page.notice("Your email is already verified."),
147 160
        Ok(()) => page.notice(format!("A verification link is on its way to {}.", me.account.email)),
…
67 unchanged lines
215 228
    let website = f.website.trim();
216 229
    if !(website.is_empty() || website.starts_with("https://") || website.starts_with("http://")) || website.len() > 200 {
217 230
        return Ok(Page(
218 -
            SettingsPage::new(shell, me.account, &headers).with_mail(&app).error("The website must be an http:// or https:// address."),
231 +
            SettingsPage::new(shell, me.account, &headers)
232 +
                .with_hub(&app)
233 +
                .await
234 +
                .error("The website must be an http:// or https:// address."),
219 235
        ));
220 236
    }
221 237
    // Accounts elsewhere: checked and normalised, or refused by name.
…
13 unchanged lines
235 251
    })();
236 252
    let (bluesky, mastodon, github) = match links {
237 253
        Ok(l) => l,
238 -
        Err(msg) => return Ok(Page(SettingsPage::new(shell, me.account, &headers).with_mail(&app).error(&msg))),
254 +
        Err(msg) => return Ok(Page(SettingsPage::new(shell, me.account, &headers).with_hub(&app).await.error(&msg))),
239 255
    };
240 256
    let bio: String = f.bio.trim().chars().take(280).collect();
241 257
    let location: String = f.location.trim().chars().take(80).collect();
…
13 unchanged lines
255 271
            Err(e) => notice = format!("Profile saved. {e}"),
256 272
        }
257 273
    }
258 -
    Ok(Page(SettingsPage::new(shell, updated, &headers).with_mail(&app).notice(notice)))
274 +
    Ok(Page(SettingsPage::new(shell, updated, &headers).with_hub(&app).await.notice(notice)))
259 275
}
260 276
261 277
#[derive(Deserialize)]
…
2 unchanged lines
264 280
    csrf_token: String,
265 281
    current: String,
266 282
    password: String,
283 +
}
284 +
285 +
#[derive(Deserialize)]
286 +
pub struct BookmarkForm {
287 +
    #[serde(rename = "_csrf")]
288 +
    csrf_token: String,
289 +
    #[serde(default)]
290 +
    default_bookmark: String,
291 +
}
292 +
293 +
/// `POST /settings/repositories` (oxygenhub#371): the default bookmark of
294 +
/// the repositories they make; empty, the site's.
295 +
pub async fn repositories(
296 +
    State(app): State<Shared>,
297 +
    viewer: Viewer,
298 +
    headers: HeaderMap,
299 +
    Form(f): Form<BookmarkForm>,
300 +
) -> Result<Page<SettingsPage>, Error> {
301 +
    let shell = Shell::new(&app, "/settings", &viewer);
302 +
    let me = require(&viewer, &shell)?;
303 +
    csrf(&viewer, &f.csrf_token, &shell)?;
304 +
    let scope = crate::repo_setup::BookmarkScope::Account(me.account.id);
305 +
    let page = match crate::repo_setup::set_bookmark_setting(&app.db, scope, &f.default_bookmark).await {
306 +
        Ok(()) => {
307 +
            accounts::audit(&app.db, Some(&me.account), "account.default_bookmark", &me.account.username, f.default_bookmark.trim())
308 +
                .await
309 +
                .ok();
310 +
            SettingsPage::new(shell, me.account, &headers).with_hub(&app).await.notice("Saved: new repositories start with that bookmark.")
311 +
        }
312 +
        Err(m) => SettingsPage::new(shell, me.account, &headers).with_hub(&app).await.error(&m),
313 +
    };
314 +
    Ok(Page(page))
267 315
}
268 316
269 317
pub async fn change_password(
…
6 unchanged lines
276 324
    let me = require(&viewer, &shell)?;
277 325
    csrf(&viewer, &f.csrf_token, &shell)?;
278 326
    if !auth::verify_password(&me.account.password_hash, &f.current) {
279 -
        return Ok(
280 -
            Page(SettingsPage::new(shell, me.account, &headers).with_mail(&app).error("Your current password is wrong.")).into_response()
281 -
        );
327 +
        return Ok(Page(SettingsPage::new(shell, me.account, &headers).with_hub(&app).await.error("Your current password is wrong."))
328 +
            .into_response());
282 329
    }
283 330
    if let Err(e) = auth::password_ok(&f.password) {
284 -
        return Ok(Page(SettingsPage::new(shell, me.account, &headers).with_mail(&app).error(e)).into_response());
331 +
        return Ok(Page(SettingsPage::new(shell, me.account, &headers).with_hub(&app).await.error(e)).into_response());
285 332
    }
286 333
    let hash = auth::hash_password(&f.password).map_err(|e| Error::bad(shell.clone(), e.to_string()))?;
287 334
    // A new session salt signs out every other session; this one is renewed.
…
576 unchanged lines
modified crates/hub/tests/api.rs +46 −3
…
914 unchanged lines
915 915
            r.body.split("oxyhub_").nth(1).map(|s| format!("oxyhub_{}", s.split('<').next().unwrap().trim())).unwrap()
916 916
        }
917 917
    };
918 -
    let a = mint(andy, "scopes=repo%3Aadmin").await;
919 -
    let b = mint(bob, "scopes=repo%3Aread&scopes=repo%3Awrite").await;
918 +
    let a = mint(andy.clone(), "scopes=repo%3Aadmin").await;
919 +
    let b = mint(bob.clone(), "scopes=repo%3Aread&scopes=repo%3Awrite").await;
920 920
    api(app, "POST", "/api/v1/repos", Some(&a), Some(r#"{"owner":"andy","name":"demo","visibility":"public"}"#)).await;
921 921
    api(app, "POST", "/api/v1/repos/andy/demo/issues", Some(&a), Some(r#"{"title":"Crash"}"#)).await;
922 922
…
242 unchanged lines
1165 1165
    let (st, _) = api(app, "POST", "/api/v1/repos", Some(&a), Some(r#"{"owner":"andy","name":"nowhere","project":{"number":99}}"#)).await;
1166 1166
    assert_eq!(st, StatusCode::UNPROCESSABLE_ENTITY, "no such board");
1167 1167
    assert_eq!(api(app, "GET", "/api/v1/repos/andy/nowhere", Some(&a), None).await.0, StatusCode::NOT_FOUND);
1168 +
1169 +
    // OxygenFlow, the wiki and the default bookmark with it (#371).
1170 +
    let a = a.as_str();
1171 +
    let made = |name: &'static str| async move { api(app, "GET", &format!("/api/v1/repos/andy/{name}"), Some(a), None).await.0 };
1172 +
    let bob_admin = mint(bob.clone(), "scopes=repo%3Aadmin").await;
1173 +
    let (st, v) = api(app, "POST", "/api/v1/repos", Some(&bob_admin), Some(r#"{"owner":"bob","name":"site","flow":"on"}"#)).await;
1174 +
    assert_eq!(st, StatusCode::FORBIDDEN, "only site administrators use the site's runners: {v}");
1175 +
    let (st, v) = api(app, "POST", "/api/v1/repos", Some(a), Some(r#"{"owner":"andy","name":"ownflow","flow":"own"}"#)).await;
1176 +
    assert_eq!(st, StatusCode::FORBIDDEN, "andy has no runners of his own: {v}");
1177 +
    assert_eq!(made("ownflow").await, StatusCode::NOT_FOUND, "nothing made");
1178 +
    let (st, _) = api(app, "POST", "/api/v1/repos", Some(a), Some(r#"{"owner":"andy","name":"x","flow":"always"}"#)).await;
1179 +
    assert_eq!(st, StatusCode::BAD_REQUEST);
1180 +
    let (st, v) = api(app, "POST", "/api/v1/repos", Some(a), Some(r#"{"owner":"andy","name":"flowing","flow":"on"}"#)).await;
1181 +
    assert_eq!(st, StatusCode::CREATED, "{v}");
1182 +
    let flow: String = sqlx::query_scalar("SELECT flow FROM repositories WHERE name = 'flowing'").fetch_one(&app.db).await.unwrap();
1183 +
    assert_eq!(flow, "on");
1184 +
    let (st, v) = api(app, "POST", "/api/v1/repos", Some(a), Some(r#"{"owner":"andy","name":"wikied","wiki":true}"#)).await;
1185 +
    assert_eq!(st, StatusCode::CONFLICT, "the wiki needs the hub as the caller's delegate: {v}");
1186 +
    assert_eq!(made("wikied").await, StatusCode::NOT_FOUND, "nothing made");
1187 +
    // The bookmark: asked for, else the owner's, else the site's, else main.
1188 +
    let bookmark = |name: &'static str| async move {
1189 +
        api(app, "GET", &format!("/api/v1/repos/andy/{name}"), Some(a), None).await.1["default_bookmark"].as_str().unwrap().to_string()
1190 +
    };
1191 +
    assert_eq!(bookmark("flowing").await, "main");
1192 +
    let (st, _) = api(app, "POST", "/api/v1/repos", Some(a), Some(r#"{"owner":"andy","name":"y","default_bookmark":"a/b"}"#)).await;
1193 +
    assert_eq!(st, StatusCode::BAD_REQUEST);
1194 +
    let page = call(app, "GET", "/admin/repositories", None, Some(&andy), None, None).await;
1195 +
    let token = csrf_of(&page.body);
1196 +
    let r = call(app, "POST", "/admin/repositories", None, Some(&andy), None, Some(&format!("_csrf={token}&default_bookmark=trunk"))).await;
1197 +
    assert!(r.body.contains("start on trunk"), "{}", r.body);
1198 +
    api(app, "POST", "/api/v1/repos", Some(a), Some(r#"{"owner":"andy","name":"sited"}"#)).await;
1199 +
    assert_eq!(bookmark("sited").await, "trunk");
1200 +
    let page = call(app, "GET", "/settings", None, Some(&andy), None, None).await;
1201 +
    assert!(page.body.contains(r#"placeholder="trunk""#), "the site's default shown");
1202 +
    let token = csrf_of(&page.body);
1203 +
    call(app, "POST", "/settings/repositories", None, Some(&andy), None, Some(&format!("_csrf={token}&default_bookmark=develop"))).await;
1204 +
    api(app, "POST", "/api/v1/repos", Some(a), Some(r#"{"owner":"andy","name":"mine"}"#)).await;
1205 +
    assert_eq!(bookmark("mine").await, "develop");
1206 +
    api(app, "POST", "/api/v1/repos", Some(a), Some(r#"{"owner":"andy","name":"asked","default_bookmark":"next"}"#)).await;
1207 +
    assert_eq!(bookmark("asked").await, "next");
1208 +
    let page = call(app, "GET", "/new", None, Some(&andy), None, None).await;
1209 +
    assert!(page.body.contains(r#"data-bookmark="develop""#), "{}", page.body);
1210 +
    assert!(page.body.contains("<h2>OxygenFlow</h2>") && page.body.contains("<h2>Wiki</h2>"));
1168 1211
}
1169 1212
1170 1213
#[tokio::test(flavor = "multi_thread")]
…
100 unchanged lines
1271 1314
    let again =
1272 1315
        call(app, "POST", "/new", None, Some(&andy), None, Some(&format!("_csrf={}&owner=andy&name=other&readme=1", csrf_of(&page.body))))
1273 1316
            .await;
1274 -
    assert!(again.body.contains("cannot write the starting files for you yet"), "no delegate: refused, nothing made");
1317 +
    assert!(again.body.contains("cannot write the starting files or the wiki for you yet"), "no delegate: refused, nothing made");
1275 1318
    assert!(oxygenhub::db::repos::get(&app.db, "andy", "other").await.unwrap().is_none());
1276 1319
    let row = || async { oxygenhub::db::repos::get(&app.db, "andy", "demo").await.unwrap().unwrap() };
1277 1320
    assert!(!oxygenhub::flow::enabled(app, &row().await), "not in [flow] repos");
…
672 unchanged lines
modified crates/oh/src/main.rs +17 −1
…
312 unchanged lines
313 313
        /// Start it with an .oxyignore for a language: Rust, Node, Python, Go, "Java and Kotlin"
314 314
        #[arg(long)]
315 315
        ignore: Option<String>,
316 +
        /// Switch OxygenFlow on: `on` (the site's runners; site administrators) or `own` (the owner's runners)
317 +
        #[arg(long)]
318 +
        flow: Option<String>,
319 +
        /// Start its wiki with a Home page
320 +
        #[arg(long)]
321 +
        wiki: bool,
322 +
        /// Its default bookmark (default: the owner's, else the site's, else main)
323 +
        #[arg(long, short = 'B')]
324 +
        bookmark: Option<String>,
316 325
    },
317 326
}
318 327
…
713 unchanged lines
1032 1041
            println!("{text}");
1033 1042
            Ok(0)
1034 1043
        }
1035 -
        Cmd::Repo { cmd: RepoCmd::Create { slug, description, visibility, project, readme, licence, ignore } } => {
1044 +
        Cmd::Repo { cmd: RepoCmd::Create { slug, description, visibility, project, readme, licence, ignore, flow, wiki, bookmark } } => {
1036 1045
            let (owner, name) = slug.split_once('/').context("give the repository as OWNER/NAME")?;
1037 1046
            let project = match project.as_deref() {
1038 1047
                None => None,
…
5 unchanged lines
1044 1053
            };
1045 1054
            let mut body = serde_json::json!({
1046 1055
                "owner": owner, "name": name, "description": description.unwrap_or_default(), "visibility": visibility, "readme": readme,
1056 +
                "wiki": wiki,
1047 1057
            });
1058 +
            if let Some(f) = flow {
1059 +
                body["flow"] = serde_json::json!(f);
1060 +
            }
1061 +
            if let Some(b) = bookmark {
1062 +
                body["default_bookmark"] = serde_json::json!(b);
1063 +
            }
1048 1064
            if let Some(p) = project {
1049 1065
                body["project"] = p;
1050 1066
            }
…
1946 unchanged lines
modified templates/admin.html +11 −0
…
59 unchanged lines
60 60
  <form class="card" method="post" action="/admin/invite"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><button type="submit">NEW INVITATION</button></form>
61 61
  {% if !invitations.is_empty() %}<ul class="plain small">{% for (code, used, at) in invitations %}<li><code>{{ code }}</code> {% if used.is_some() %}<span class="mute">used</span>{% else %}<a href="/register?invite={{ code }}">link</a>{% endif %} · {{ at|ago_i }}</li>{% endfor %}</ul>{% endif %}
62 62
</section>
63 +
{% when crate::web::admin::AdminSection::NewRepositories %}
64 +
<section class="panel" id="repositories">
65 +
  <h2>New repositories</h2>
66 +
  <form class="card" method="post" action="/admin/repositories">
67 +
    <input type="hidden" name="_csrf" value="{{ shell.csrf }}">
68 +
    <label for="sitebookmark">Default bookmark</label>
69 +
    <input type="text" id="sitebookmark" name="default_bookmark" value="{{ site_bookmark }}" placeholder="main" pattern="[A-Za-z0-9][A-Za-z0-9._-]{0,63}" autocomplete="off" spellcheck="false">
70 +
    <button type="submit">SAVE</button>
71 +
  </form>
72 +
  <p class="small mute">The bookmark a new repository starts with, such as <code>develop</code>. A user or an organisation may choose their own in their settings, and each repository on the New repository page. Left empty: <code>main</code>.</p>
73 +
</section>
63 74
{% when crate::web::admin::AdminSection::Organisations %}
64 75
<section class="panel" id="organisations">
65 76
  <h2>Organisations</h2>
…
75 unchanged lines
modified templates/org_settings.html +3 −0
…
29 unchanged lines
30 30
  <label for="description">Description</label><input type="text" id="description" name="description" value="{{ org.description }}">
31 31
  <label for="default_role">Members' default role on the organisation's repositories</label>
32 32
  <select id="default_role" name="default_role">{% for r in ["none", "read", "triage", "write", "maintain", "admin"] %}<option value="{{ r }}"{% if org.default_role.as_str() == *r %} selected{% endif %}>{{ r }}</option>{% endfor %}</select>
33 +
  <label for="default_bookmark">New repositories' default bookmark</label>
34 +
  <input type="text" id="default_bookmark" name="default_bookmark" value="{{ default_bookmark }}" placeholder="{{ site_bookmark }}" pattern="[A-Za-z0-9][A-Za-z0-9._-]{0,63}" autocomplete="off" spellcheck="false">
35 +
  <p class="small mute">Such as <code>develop</code>. Left empty, they take this site's: <code>{{ site_bookmark }}</code>. Each repository can still choose its own.</p>
33 36
  <button type="submit">SAVE</button>
34 37
</form>
35 38
<form class="card picture-card" id="logo" method="post" action="/orgs/{{ org.name }}/settings/avatar" enctype="multipart/form-data">
…
133 unchanged lines
modified templates/repo_new.html +71 −37
…
2 unchanged lines
3 3
{% block content %}
4 4
<div class="head"><h1>New repository</h1></div>
5 5
{% if let Some(e) = error %}<p class="error">{{ e }}</p>{% endif %}
6 -
<form class="card new-repo" method="post" action="/new" data-new-repo>
6 +
<form class="card full new-repo" method="post" action="/new" data-new-repo>
7 7
  <input type="hidden" name="_csrf" value="{{ shell.csrf }}">
8 -
  <div class="row name-row">
9 -
    <div><label for="owner">Owner</label><select id="owner" name="owner" data-orgs="{% for o in orgs %}{{ o }} {% endfor %}">{% for o in owners %}<option value="{{ o }}">{{ o }}</option>{% endfor %}</select></div>
10 -
    <div class="slash" aria-hidden="true">/</div>
11 -
    <div class="grow"><label for="name">Name</label><input type="text" id="name" name="name" required pattern="[A-Za-z0-9][A-Za-z0-9._-]{0,63}" autocomplete="off" aria-describedby="name-check" autofocus></div>
12 -
  </div>
13 -
  <p id="name-check" class="small name-check" role="status" aria-live="polite" data-base="{{ base_url }}"></p>
14 -
  <label for="description">Description <span class="small mute">(optional)</span></label><input type="text" id="description" name="description">
15 8
16 -
  <fieldset class="choices">
17 -
    <legend>Visibility</legend>
18 -
    <label class="choice-card"><input type="radio" name="visibility" value="public" checked><span><strong>Public</strong><span class="small mute">Anyone can see and clone it. You choose who may push.</span></span></label>
19 -
    <label class="choice-card"><input type="radio" name="visibility" value="internal"><span><strong>Internal</strong><span class="small mute">Anyone signed in to this hub can see it.</span></span></label>
20 -
    <label class="choice-card"><input type="radio" name="visibility" value="private"><span><strong>Private</strong><span class="small mute">Only you{% if !orgs.is_empty() %}, the organisation's members{% endif %} and the people you add.</span></span></label>
21 -
  </fieldset>
9 +
  <section class="nr-sec">
10 +
    <div class="nr-head"><h2>Repository</h2><p class="small mute">Where it lives and what it is called.</p></div>
11 +
    <div class="nr-body">
12 +
      <div class="row name-row">
13 +
        <div><label for="owner">Owner</label><select id="owner" name="owner" data-orgs="{% for o in orgs %}{{ o }} {% endfor %}" data-runners="{% for o in runner_owners %}{{ o }} {% endfor %}">{% for (o, b, from) in bookmarks %}<option value="{{ o }}" data-bookmark="{{ b }}" data-bookmark-from="{{ from }}">{{ o }}</option>{% endfor %}</select></div>
14 +
        <div class="slash" aria-hidden="true">/</div>
15 +
        <div class="grow"><label for="name">Name</label><input type="text" id="name" name="name" required pattern="[A-Za-z0-9][A-Za-z0-9._-]{0,63}" autocomplete="off" aria-describedby="name-check" autofocus></div>
16 +
      </div>
17 +
      <p id="name-check" class="small name-check" role="status" aria-live="polite" data-base="{{ base_url }}"></p>
18 +
      <label for="description">Description <span class="small mute">(optional; the README and the wiki start with it)</span></label><input type="text" id="description" name="description">
19 +
      <label for="default_bookmark">Default bookmark</label><input type="text" id="default_bookmark" class="nr-bookmark" name="default_bookmark" pattern="[A-Za-z0-9][A-Za-z0-9._-]{0,63}" autocomplete="off" spellcheck="false" aria-describedby="bookmark-from" data-default-bookmark>
20 +
      <p id="bookmark-from" class="small mute nr-hint" data-bookmark-hint></p>
21 +
    </div>
22 +
  </section>
23 +
24 +
  <section class="nr-sec">
25 +
    <div class="nr-head"><h2>Visibility</h2><p class="small mute">Who can see and clone it. You can change it later in Settings.</p></div>
26 +
    <div class="nr-body nr-cards three" role="radiogroup" aria-label="Visibility">
27 +
      <label class="choice-card"><input type="radio" name="visibility" value="public" checked><span><strong>Public</strong><span class="small mute">Anyone can see and clone it. You choose who may push.</span></span></label>
28 +
      <label class="choice-card"><input type="radio" name="visibility" value="internal"><span><strong>Internal</strong><span class="small mute">Anyone signed in to this hub can see it.</span></span></label>
29 +
      <label class="choice-card"><input type="radio" name="visibility" value="private"><span><strong>Private</strong><span class="small mute">Only you{% if !orgs.is_empty() %}, the organisation's members{% endif %} and the people you add.</span></span></label>
30 +
    </div>
31 +
  </section>
32 +
33 +
  <section class="nr-sec">
34 +
    <div class="nr-head"><h2>Starting files</h2><p class="small mute">Optional. Left empty, it starts empty and takes its first push. The hub commits these as you, signed with its key as your delegate.</p></div>
35 +
    <div class="nr-body">
36 +
      <label class="nr-toggle"><input type="checkbox" name="readme" value="1"><span><strong>README</strong><span class="small mute">Its name and description, as README.md.</span></span></label>
37 +
      <div class="row nr-pair">
38 +
        <div><label for="licence">Licence</label><select id="licence" name="licence"><option value="">none</option>{% for (id, what) in licences %}<option value="{{ id }}">{{ id }}: {{ what }}</option>{% endfor %}</select></div>
39 +
        <div><label for="ignore">.oxyignore</label><select id="ignore" name="ignore"><option value="">none</option>{% for l in ignores %}<option>{{ l }}</option>{% endfor %}</select></div>
40 +
      </div>
41 +
    </div>
42 +
  </section>
43 +
44 +
  <section class="nr-sec">
45 +
    <div class="nr-head"><h2>Wiki</h2><p class="small mute">Pages beside the code, cloned as its <code>.wiki</code>.</p></div>
46 +
    <div class="nr-body">
47 +
      <label class="nr-toggle"><input type="checkbox" name="wiki" value="1"><span><strong>Start the wiki</strong><span class="small mute">A Home page with its name and description, ready to edit. Left off, the first page anyone writes starts it.</span></span></label>
48 +
    </div>
49 +
  </section>
22 50
23 -
  <fieldset class="choices">
24 -
    <legend>Start with <span class="small mute">(optional; left empty, it starts empty and takes its first push)</span></legend>
25 -
    <label class="check"><input type="checkbox" name="readme" value="1"> a README with its name and description</label>
26 -
    <div class="row">
27 -
      <div><label for="licence">Licence</label><select id="licence" name="licence"><option value="">none</option>{% for (id, what) in licences %}<option value="{{ id }}">{{ id }}: {{ what }}</option>{% endfor %}</select></div>
28 -
      <div><label for="ignore">.oxyignore</label><select id="ignore" name="ignore"><option value="">none</option>{% for l in ignores %}<option>{{ l }}</option>{% endfor %}</select></div>
51 +
  <section class="nr-sec">
52 +
    <div class="nr-head"><h2>Project board</h2><p class="small mute">Its issues and proposals join the board as they open.</p></div>
53 +
    <div class="nr-body">
54 +
      <div class="nr-cards three" role="radiogroup" aria-label="Project board">
55 +
        <label class="choice-card"><input type="radio" name="board" value="" checked data-board-none><span><strong>No board</strong><span class="small mute">Add its issues to a board later.</span></span></label>
56 +
        <label class="choice-card"><input type="radio" name="board" value="new" data-board-new><span><strong>A new board</strong><span class="small mute">Backlog, Doing and Done; finished work moves to Done.</span></span></label>
57 +
        {% if !boards.is_empty() %}<label class="choice-card"><input type="radio" name="board" value="existing" data-board-existing><span><strong>An existing board</strong><span class="small mute">One of the owner's open boards.</span></span></label>{% endif %}
58 +
      </div>
59 +
      <div class="board-title"><label for="board_title">Board title</label><input type="text" id="board_title" name="board_title" maxlength="100" placeholder="the repository's name"></div>
60 +
      {% if !boards.is_empty() %}<div class="board-existing"><label for="board_n">Board</label><select id="board_n" name="board_n" data-board-list>{% for (o, n, t) in boards %}<option value="{{ n }}" data-owner="{{ o }}">{{ o }} #{{ n }}: {{ t }}</option>{% endfor %}</select></div>{% endif %}
29 61
    </div>
30 -
    <p class="small mute">The hub commits these as you, signed with its key as your delegate, as it does for edits on the site.</p>
31 -
  </fieldset>
62 +
  </section>
32 63
33 -
  <fieldset class="choices">
34 -
    <legend>Project board</legend>
35 -
    <label class="choice-card"><input type="radio" name="board" value="" checked data-board-none><span><strong>No board</strong><span class="small mute">Add its issues to a board later.</span></span></label>
36 -
    <label class="choice-card"><input type="radio" name="board" value="new" data-board-new><span><strong>A new board</strong><span class="small mute">Backlog, Doing and Done, with finished work moving to Done. Its issues and proposals join the board as they open.</span></span></label>
37 -
    <div class="board-title"><label for="board_title">Board title</label><input type="text" id="board_title" name="board_title" maxlength="100" placeholder="the repository's name"></div>
38 -
    {% if !boards.is_empty() %}<label class="choice-card"><input type="radio" name="board" value="existing" data-board-existing><span><strong>An existing board</strong><span class="small mute">Its issues and proposals join it as they open.</span></span></label>
39 -
    <div class="board-existing"><label for="board_n">Board</label><select id="board_n" name="board_n" data-board-list>{% for (o, n, t) in boards %}<option value="{{ n }}" data-owner="{{ o }}">{{ o }} #{{ n }}: {{ t }}</option>{% endfor %}</select></div>{% endif %}
40 -
  </fieldset>
64 +
  <section class="nr-sec">
65 +
    <div class="nr-head"><h2>OxygenFlow</h2><p class="small mute">Pipelines: once it is on, every push with a <code>.flow/pipeline.kdl</code> starts a run, and its jobs show as checks.</p></div>
66 +
    <div class="nr-body nr-cards three" role="radiogroup" aria-label="OxygenFlow">
67 +
      <label class="choice-card"><input type="radio" name="flow" value="" checked><span><strong>Not yet</strong><span class="small mute">The site's default. Switch it on later in Settings › Pipelines.</span></span></label>
68 +
      <label class="choice-card{% if !admin %} off{% endif %}"><input type="radio" name="flow" value="on"{% if !admin %} disabled{% endif %}><span><strong>On, site runners</strong><span class="small mute">{% if admin %}The site's runners take its jobs.{% else %}A site administrator's choice.{% endif %}</span></span></label>
69 +
      <label class="choice-card" data-flow-own><input type="radio" name="flow" value="own"><span><strong>On, own runners</strong><span class="small mute" data-flow-own-note>Only the owner's own runners take its jobs.</span></span></label>
70 +
    </div>
71 +
  </section>
41 72
42 -
  <details class="storage"><summary>Storage <span class="small mute">(this server's disk, unless you keep it in your own bucket)</span></summary>
43 -
  <label>Storage</label>
44 -
  <div class="choice">
45 -
    <label><input type="radio" name="storage" value="hub" checked> this server's disk</label>
46 -
    <label><input type="radio" name="storage" value="bucket"> my own bucket (Amazon S3, Google Cloud Storage or Azure)</label>
47 -
  </div>
73 +
  <details class="nr-sec nr-storage">
74 +
    <summary><h2>Storage</h2><span class="small mute">This server's disk, unless you keep it in your own bucket.</span></summary>
75 +
    <div class="nr-body">
76 +
      <div class="nr-cards two">
77 +
        <label class="choice-card"><input type="radio" name="storage" value="hub" checked><span><strong>This server's disk</strong><span class="small mute">The default. Backed up with the hub.</span></span></label>
78 +
        <label class="choice-card"><input type="radio" name="storage" value="bucket"><span><strong>My own bucket</strong><span class="small mute">Amazon S3, Google Cloud Storage or Azure; this server keeps a cache. It starts empty.</span></span></label>
79 +
      </div>
48 80
  <fieldset class="storage-bucket">
49 81
    <legend>Your bucket <small class="mute">(only when kept in your own bucket)</small></legend>
50 82
    <p class="small mute">The repository's data lives in the bucket and this server keeps a cache. The bucket, or the prefix in it, must be empty. It is checked before anything is created. The credentials are sealed on this server and used only for this repository; give them read, write, list and delete under the prefix and nothing more.</p>
…
12 unchanged lines
63 95
      <div><label for="client_secret">Client secret</label><input type="password" id="client_secret" name="client_secret" autocomplete="new-password"></div>
64 96
    </div>
65 97
  </fieldset>
98 +
    </div>
66 99
  </details>
67 -
  <button type="submit">CREATE REPOSITORY</button>
100 +
101 +
  <div class="nr-foot"><button type="submit">CREATE REPOSITORY</button></div>
68 102
</form>
69 103
{% endblock %}
modified templates/settings.html +9 −0
…
9 unchanged lines
10 10
<nav class="section-nav" aria-label="Sections">
11 11
  <a href="#profile">Profile</a>
12 12
  <a href="#picture">Picture</a>
13 +
  <a href="#repositories">New repositories</a>
13 14
  <a href="#appearance">Appearance</a>
14 15
  <a href="#password">Password</a>
15 16
</nav>
…
27 unchanged lines
43 44
    </div>
44 45
  </div>
45 46
</form>
47 +
<form class="card" id="repositories" method="post" action="/settings/repositories">
48 +
  <input type="hidden" name="_csrf" value="{{ shell.csrf }}">
49 +
  <h2>New repositories</h2>
50 +
  <label for="default_bookmark">Default bookmark</label>
51 +
  <input type="text" id="default_bookmark" name="default_bookmark" value="{{ default_bookmark }}" placeholder="{{ site_bookmark }}" pattern="[A-Za-z0-9][A-Za-z0-9._-]{0,63}" autocomplete="off" spellcheck="false">
52 +
  <p class="small mute">The bookmark your new repositories start with, such as <code>develop</code>. Left empty, they take this site's: <code>{{ site_bookmark }}</code>. An organisation sets its own under its settings.</p>
53 +
  <button type="submit">SAVE</button>
54 +
</form>
46 55
<form class="card" id="appearance" method="post" action="/settings/appearance">
47 56
  <input type="hidden" name="_csrf" value="{{ shell.csrf }}">
48 57
  <h2>Appearance</h2>
…
25 unchanged lines
modified templates/style.css +44 −10
…
1551 unchanged lines
1552 1552
.tab-job .ok { color: var(--ok); }
1553 1553
.tab-job .bad { color: var(--bad); }
1554 1554
.tab-job pre { max-height: 24rem; overflow: auto; }
1555 -
/* The New repository page (#360). */
1555 +
/* The New repository page (#360, #371): sections with a heading column on
1556 +
   the left and their choices on the right, stacked on a phone. */
1557 +
form.card.new-repo { max-width: 960px; padding: 0; }
1558 +
.new-repo .nr-sec { display: grid; grid-template-columns: minmax(0, 220px) minmax(0, 1fr); gap: 8px 28px; padding: 20px 24px; border-top: 1px solid var(--line); }
1559 +
.new-repo .nr-sec:first-of-type { border-top: 0; }
1560 +
.new-repo .nr-sec > .nr-head h2, .new-repo .nr-storage summary h2 { font-size: 15px; margin: 0 0 4px; color: var(--ink); }
1561 +
.new-repo .nr-sec > .nr-head p { margin: 0; }
1562 +
.new-repo .nr-body { min-width: 0; }
1563 +
.new-repo .nr-body > label:first-child, .new-repo .name-row label { margin-top: 0; }
1556 1564
.new-repo .name-row { display: flex; align-items: flex-end; gap: 8px; }
1557 1565
.new-repo .name-row .grow { flex: 1; min-width: 0; }
1558 -
.new-repo .name-row .slash { font-size: 20px; color: var(--mute); padding-bottom: 6px; }
1559 -
.new-repo .name-check { min-height: 1.4em; margin: 4px 0 8px; }
1566 +
.new-repo .name-row .slash { flex: none; font-size: 20px; color: var(--mute); padding-bottom: 6px; }
1567 +
.new-repo .name-check { min-height: 1.4em; margin: 4px 0 4px; }
1560 1568
.ok-text { color: var(--ok); }
1561 1569
.err-text { color: var(--bad); }
1562 -
.new-repo fieldset.choices { border: 1px solid var(--line); padding: 10px 12px; margin: 12px 0; display: grid; gap: 8px; }
1563 -
.new-repo fieldset.choices legend { padding: 0 4px; font-weight: 600; }
1564 -
.choice-card { display: flex; gap: 10px; align-items: flex-start; padding: 8px 10px; border: 1px solid var(--line); background: var(--soft); cursor: pointer; }
1565 -
.choice-card > span { display: grid; gap: 2px; }
1566 -
.choice-card:has(input:checked) { border-color: var(--acc); }
1567 -
.new-repo .board-title, .new-repo .board-existing { margin-left: 28px; }
1568 -
.new-repo details.storage { margin: 12px 0; }
1570 +
.new-repo .nr-cards { display: grid; gap: 10px; align-content: start; }
1571 +
.new-repo .nr-cards.three { grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); }
1572 +
.new-repo .nr-cards.two { grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); margin-bottom: 10px; }
1573 +
.new-repo label.choice-card, .new-repo label.nr-toggle { display: flex; gap: 10px; align-items: flex-start; margin: 0; padding: 10px 12px; border: 1px solid var(--line); background: var(--soft); cursor: pointer; font-size: 14px; text-transform: none; letter-spacing: 0; color: var(--ink); }
1574 +
.new-repo label.choice-card input, .new-repo label.nr-toggle input { margin: 3px 0 0; flex: none; }
1575 +
.new-repo label.choice-card > span, .new-repo label.nr-toggle > span { display: grid; gap: 2px; min-width: 0; }
1576 +
.new-repo label.choice-card:hover, .new-repo label.nr-toggle:hover { border-color: var(--acc); }
1577 +
.new-repo label.choice-card:has(input:checked), .new-repo label.nr-toggle:has(input:checked) { border-color: var(--acc); box-shadow: inset 3px 0 0 var(--acc); }
1578 +
.new-repo label.choice-card:has(input:focus-visible), .new-repo label.nr-toggle:has(input:focus-visible) { outline: 2px solid var(--acc); outline-offset: 1px; }
1579 +
.new-repo label.choice-card.off { cursor: not-allowed; opacity: .6; }
1580 +
.new-repo label.choice-card.off:hover { border-color: var(--line); }
1581 +
.new-repo .nr-pair { margin-top: 4px; flex-wrap: wrap; }
1582 +
.new-repo .nr-pair > div { flex: 1 1 200px; min-width: 0; }
1583 +
.new-repo .nr-hint { margin: 4px 0 0; }
1584 +
.new-repo input.nr-bookmark { max-width: 260px; }
1585 +
.new-repo .name-row > div:first-child { flex: 0 1 220px; min-width: 0; }
1586 +
.new-repo .name-row .grow { flex: 1 1 auto; }
1587 +
.new-repo .name-row select, .new-repo .name-row input { width: 100%; }
1588 +
.new-repo .board-title, .new-repo .board-existing { margin-top: 6px; max-width: 420px; }
1589 +
.new-repo details.nr-storage > summary { grid-column: 1 / -1; display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 12px; cursor: pointer; list-style: none; }
1590 +
.new-repo details.nr-storage > summary::-webkit-details-marker { display: none; }
1591 +
.new-repo details.nr-storage > summary h2::before { content: "\25B8"; display: inline-block; width: 1em; color: var(--mute); }
1592 +
.new-repo details.nr-storage[open] > summary h2::before { content: "\25BE"; }
1593 +
.new-repo details.nr-storage > .nr-body { grid-column: 2; }
1594 +
.new-repo .nr-storage:not(:has(input[name=storage][value=bucket]:checked)) .storage-bucket { display: none; }
1595 +
.new-repo .nr-foot { display: flex; justify-content: flex-end; padding: 16px 24px; border-top: 1px solid var(--line); background: var(--soft); }
1596 +
.new-repo .nr-foot button { margin: 0; }
1597 +
@media (max-width: 720px) {
1598 +
  .new-repo .nr-sec { grid-template-columns: minmax(0, 1fr); padding: 16px; }
1599 +
  .new-repo details.nr-storage > .nr-body { grid-column: 1; }
1600 +
  .new-repo .nr-foot { padding: 12px 16px; }
1601 +
  .new-repo .nr-foot button { width: 100%; }
1602 +
}