A default bookmark must exist: settings refuse a missing one, the overview stands in main with a note, an unknown name is not a bookmark, tag or change; Bookmarks › New bookmark makes one on the web (closes #345) 351cf57e5608
- Author
- Andy McCall @andymccall <[email protected]>
- via
- agent
agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key 3a2338c9c706
- When
- · 2026-10-27 17:56 UTC
- Signature
- ◈ sealed by agent:claude@andymccall
- Hash
351cf57e5608e7486d534fae73a0bfe833c800684179121a555b643e9f55aae3- Depends on
- 046e3352cdfd 1553bfc9ae53 19bb31ff1f5c 3f4b0ea6eabf 83d5052bf76c a7e6e8e6094c b5668108487f ba0a55bb5b3c d23464c0ac60 e271a37b629a ffb67a81188c
- Depended on by
- 217814c239f0
- In bookmarks
- main
- In tags
- v0.9.11 v0.9.15 v0.9.16 v0.9.19 v0.9.20 v0.9.21 v0.9.22 v0.9.23 v0.9.24
- Edits
- 17 atoms, 0 tree operations, 8 files
Browse the tree at this change
modified CHANGELOG.md +2 −0
| … | 3 unchanged lines |
||
| 4 | 4 | ||
| 5 | 5 | ## Unreleased |
|
| 6 | 6 | ||
| 7 | + | - **A default bookmark must exist, and bookmarks are made on the web (#345).** Settings refused nothing, so a default named before it existed (doomsdayone/tetriblox's `develop`) left the repository's pages saying `"develop" is not a change`. Settings now refuse a new default that is not a bookmark (an empty repository may name any, for its first push) and list those that are; a missing default leaves the overview showing main, or the first bookmark, with a note; an unknown name is "not a bookmark, tag or change of this repository". Bookmarks › New bookmark makes one from a bookmark, tag or change, as the user's delegate or, without a trust policy, unsigned as a push would be, so a proposal can then target it. |
|
| 8 | + | ||
| 7 | 9 | - **Iterations on project boards (#343).** A board can run an iteration: a name and its first and last days. The board shows it above the columns, day N of M and the cards done so far, with a burn-up of cards done per day. Closing it archives the cards in the Done column (the one that takes finished work, or else the last) under it; its page, linked from "past iterations", lists them, restorable, with its burn-up. One iteration is open at a time. Migration 0100. |
|
| 8 | 10 | ||
| 9 | 11 | - **`oh` on Haiku (#344).** `oh` builds and runs on Haiku R1/beta6 (x86_64); `oh-VERSION-haiku-x86_64.tar.gz` is on the releases page from 0.9.9, with its own `SHA256SUMS-haiku`, and the README says how to install it. |
|
| … | 372 unchanged lines |
modified crates/hub/src/repos/view.rs +28 −4
| … | 102 unchanged lines |
||
| 103 | 103 | Ok(RefTarget { label: hash.to_hex(), kind: RefKind::Change, heads: vec![hash] }) |
|
| 104 | 104 | } |
|
| 105 | 105 | ||
| 106 | + | /// The bookmark to show when the default bookmark does not exist: main, |
|
| 107 | + | /// else master, else the first by name; `None` without any. |
|
| 108 | + | fn stand_in(repo: &Repo) -> Result<Option<String>> { |
|
| 109 | + | let view = repo.view()?; |
|
| 110 | + | let names: Vec<&String> = view.bookmarks.keys().collect(); |
|
| 111 | + | Ok(["main", "master"] |
|
| 112 | + | .iter() |
|
| 113 | + | .find(|n| names.iter().any(|b| b == *n)) |
|
| 114 | + | .map(|n| n.to_string()) |
|
| 115 | + | .or_else(|| names.iter().min().map(|b| b.to_string()))) |
|
| 116 | + | } |
|
| 117 | + | ||
| 106 | 118 | /// A change by full hash or unique hex prefix, searched over every |
|
| 107 | 119 | /// bookmark's state. |
|
| 108 | 120 | pub fn resolve_change(repo: &Repo, cache: &StateCache, spec: &str) -> Result<ChangeHash> { |
|
| 109 | 121 | let spec = spec.trim_start_matches('#'); |
|
| 110 | 122 | if spec.len() < 4 || !spec.chars().all(|c| c.is_ascii_hexdigit()) { |
|
| 111 | - | return Err(fail(format!("{spec:?} is not a change"))); |
|
| 123 | + | return Err(fail(format!("{spec:?} is not a bookmark, tag or change of this repository"))); |
|
| 112 | 124 | } |
|
| 113 | 125 | if let Some(h) = ChangeHash::from_hex(spec) { |
|
| 114 | 126 | if repo.store().has(&h) { |
|
| … | 800 unchanged lines |
||
| 915 | 927 | pub policy: PolicyBadge, |
|
| 916 | 928 | pub contributors: Vec<Contributor>, |
|
| 917 | 929 | pub languages: Vec<LanguageShare>, |
|
| 930 | + | /// The bookmark shown in the default's place when the repository has |
|
| 931 | + | /// no bookmark of the default's name (a default set before its first |
|
| 932 | + | /// push, or one deleted since). |
|
| 933 | + | pub standing_in: Option<String>, |
|
| 918 | 934 | } |
|
| 919 | 935 | ||
| 920 | 936 | impl Overview { |
|
| … | 271 unchanged lines |
||
| 1192 | 1208 | /// The overview of `shown` (a bookmark or tag; the default bookmark |
|
| 1193 | 1209 | /// unless the viewer chose another, oxygenhub#191). |
|
| 1194 | 1210 | pub fn overview(repo: &Repo, cache: &StateCache, default_bookmark: &str, shown: &str) -> Result<Overview> { |
|
| 1195 | - | let target = match resolve(repo, cache, shown) { |
|
| 1196 | - | Ok(t) => t, |
|
| 1197 | - | Err(_) => RefTarget { label: shown.into(), kind: RefKind::Bookmark, heads: vec![] }, |
|
| 1211 | + | let missing = || RefTarget { label: shown.into(), kind: RefKind::Bookmark, heads: vec![] }; |
|
| 1212 | + | let (target, standing_in) = match resolve(repo, cache, shown) { |
|
| 1213 | + | Ok(t) => (t, None), |
|
| 1214 | + | // No bookmark of the default's name: show main, or else the first |
|
| 1215 | + | // bookmark, and say so, rather than an empty repository. |
|
| 1216 | + | Err(_) if shown == default_bookmark => match stand_in(repo)? { |
|
| 1217 | + | Some(b) => (resolve(repo, cache, &b)?, Some(b)), |
|
| 1218 | + | None => (missing(), None), |
|
| 1219 | + | }, |
|
| 1220 | + | Err(_) => (missing(), None), |
|
| 1198 | 1221 | }; |
|
| 1199 | 1222 | let heads = target.heads.len(); |
|
| 1200 | 1223 | // One load of the default state serves the counts, the latest change |
|
| … | 26 unchanged lines |
||
| 1227 | 1250 | policy: policy_badge(repo), |
|
| 1228 | 1251 | contributors: contributors(repo, state), |
|
| 1229 | 1252 | languages: languages(snapshot), |
|
| 1253 | + | standing_in, |
|
| 1230 | 1254 | }) |
|
| 1231 | 1255 | } |
|
| 1232 | 1256 | ||
| … | 320 unchanged lines |
modified crates/hub/src/web/mod.rs +1 −0
| … | 245 unchanged lines |
||
| 246 | 246 | .route("/{owner}/{name}/compare/{spec}", get(history::compare)) |
|
| 247 | 247 | .route("/{owner}/{name}/bookmarks", get(refs::bookmarks)) |
|
| 248 | 248 | .route("/{owner}/{name}/bookmarks/delete", post(refs::delete_bookmark)) |
|
| 249 | + | .route("/{owner}/{name}/bookmarks/create", post(refs::create_bookmark)) |
|
| 249 | 250 | .route("/{owner}/{name}/tags", get(refs::tags)) |
|
| 250 | 251 | .route("/{owner}/{name}/tags/create", post(refs::create_tag)) |
|
| 251 | 252 | .route("/{owner}/{name}/tags/withdraw", post(refs::withdraw_tag)) |
|
| … | 711 unchanged lines |
modified crates/hub/src/web/refs.rs +108 −1
| … | 14 unchanged lines |
||
| 15 | 15 | shell: Shell, |
|
| 16 | 16 | repo: RepoShell, |
|
| 17 | 17 | bookmarks: Vec<view::BookmarkView>, |
|
| 18 | - | /// Whether the viewer may delete bookmarks (write access). |
|
| 18 | + | /// Whether the viewer may create and delete bookmarks (write access). |
|
| 19 | 19 | can_delete: bool, |
|
| 20 | 20 | /// Whether the viewer may run the pipeline of a bookmark. |
|
| 21 | 21 | can_run: bool, |
|
| … | 119 unchanged lines |
||
| 141 | 141 | .await |
|
| 142 | 142 | } |
|
| 143 | 143 | ||
| 144 | + | #[derive(Deserialize)] |
|
| 145 | + | pub struct CreateBookmarkForm { |
|
| 146 | + | #[serde(rename = "_csrf")] |
|
| 147 | + | csrf_token: String, |
|
| 148 | + | name: String, |
|
| 149 | + | /// A bookmark, tag or change; the default bookmark when empty. |
|
| 150 | + | #[serde(default)] |
|
| 151 | + | from: String, |
|
| 152 | + | } |
|
| 153 | + | ||
| 154 | + | /// Create a bookmark from the web at the heads of a bookmark, tag or change: |
|
| 155 | + | /// what pushing a new bookmark does, signed by the hub key as the user's |
|
| 156 | + | /// delegate, as the web's other changes to bookmarks are. |
|
| 157 | + | pub async fn create_bookmark( |
|
| 158 | + | State(app): State<Shared>, |
|
| 159 | + | viewer: Viewer, |
|
| 160 | + | Path((owner, name)): Path<(String, String)>, |
|
| 161 | + | Form(f): Form<CreateBookmarkForm>, |
|
| 162 | + | ) -> Result<Page<BookmarksPage>, Error> { |
|
| 163 | + | let shell = Shell::new(&app, &format!("/{owner}/{name}/bookmarks"), &viewer); |
|
| 164 | + | let me = require(&viewer, &shell)?; |
|
| 165 | + | let (repo, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Write).await?; |
|
| 166 | + | csrf(&viewer, &f.csrf_token, &shell)?; |
|
| 167 | + | let bookmark = f.name.trim().to_string(); |
|
| 168 | + | let from = match f.from.trim() { |
|
| 169 | + | "" => row.default_bookmark.clone(), |
|
| 170 | + | s => s.to_string(), |
|
| 171 | + | }; |
|
| 172 | + | if bookmark.is_empty() || bookmark.chars().any(char::is_whitespace) { |
|
| 173 | + | return bookmarks_page(&app, shell, repo, row, role, None, Some("A bookmark's name has no spaces.".into())).await; |
|
| 174 | + | } |
|
| 175 | + | let (wanted, spec) = (bookmark.clone(), from.clone()); |
|
| 176 | + | let found = app |
|
| 177 | + | .repos |
|
| 178 | + | .read(&repo, move |r, cache| { |
|
| 179 | + | if r.view()?.bookmarks.contains_key(&wanted) { |
|
| 180 | + | return Ok(Err(format!("{wanted} is a bookmark already."))); |
|
| 181 | + | } |
|
| 182 | + | Ok(view::resolve(r, cache, &spec).map(|t| t.heads).map_err(|e| e.to_string())) |
|
| 183 | + | }) |
|
| 184 | + | .await |
|
| 185 | + | .map_err(|e| Error::from_repo(shell.clone(), e))?; |
|
| 186 | + | let heads = match found { |
|
| 187 | + | Ok(heads) if !heads.is_empty() => heads, |
|
| 188 | + | Ok(_) => return bookmarks_page(&app, shell, repo, row, role, None, Some(format!("{from} holds no changes to start from."))).await, |
|
| 189 | + | Err(msg) => return bookmarks_page(&app, shell, repo, row, role, None, Some(msg)).await, |
|
| 190 | + | }; |
|
| 191 | + | // As the user's delegate when they have one; otherwise unsigned, which |
|
| 192 | + | // a repository without a trust policy takes, as a push without one. |
|
| 193 | + | let made = match super::edit::signer_for(&app, me.account.id).await { |
|
| 194 | + | Ok(signer) => crate::repos::edits::move_bookmark(&app.repos, &repo, bookmark.clone(), heads.clone(), signer) |
|
| 195 | + | .await |
|
| 196 | + | .map(|_| ()) |
|
| 197 | + | .map_err(|e| e.to_string()), |
|
| 198 | + | Err(why) => { |
|
| 199 | + | let (target, at) = (bookmark.clone(), heads.clone()); |
|
| 200 | + | let unsigned = app |
|
| 201 | + | .repos |
|
| 202 | + | .write(&repo, move |r, _| { |
|
| 203 | + | let update = r.host_ref_update(&target, &at, None)?; |
|
| 204 | + | let report = r.receive_push_report(Default::default(), vec![update], None, vec![])?; |
|
| 205 | + | Ok(match report.results.first() { |
|
| 206 | + | Some(res) if res.ok => Ok(()), |
|
| 207 | + | Some(res) => Err(res.reason.clone()), |
|
| 208 | + | None => Err("nothing happened".to_string()), |
|
| 209 | + | }) |
|
| 210 | + | }) |
|
| 211 | + | .await |
|
| 212 | + | .map_err(|e| e.to_string()) |
|
| 213 | + | .and_then(|r| r); |
|
| 214 | + | unsigned.map_err(|refused| format!("{refused}. {why}")) |
|
| 215 | + | } |
|
| 216 | + | }; |
|
| 217 | + | if let Err(e) = made { |
|
| 218 | + | return bookmarks_page(&app, shell, repo, row, role, None, Some(format!("Could not create {bookmark}: {e}"))).await; |
|
| 219 | + | } |
|
| 220 | + | let made = bookmark.clone(); |
|
| 221 | + | let new_state = app |
|
| 222 | + | .repos |
|
| 223 | + | .read(&repo, move |r, _| Ok(r.view()?.bookmarks.get(&made).map(|s| s.to_hex()).unwrap_or_default())) |
|
| 224 | + | .await |
|
| 225 | + | .unwrap_or_default(); |
|
| 226 | + | let result = db::repos::PushOutcome { |
|
| 227 | + | reference: bookmark.clone(), |
|
| 228 | + | ok: true, |
|
| 229 | + | reason: format!("created on the web from {from}"), |
|
| 230 | + | old_state: oxy_core::hash::ObjectRef::ZERO.to_hex(), |
|
| 231 | + | new_state: new_state.clone(), |
|
| 232 | + | heads: heads.len() as i64, |
|
| 233 | + | changes: 0, |
|
| 234 | + | }; |
|
| 235 | + | db::repos::record_push(&app.db, row.id, Some((me.account.id, &me.account.username)), &[result]).await.ok(); |
|
| 236 | + | crate::hooks::emit( |
|
| 237 | + | &app, |
|
| 238 | + | &row, |
|
| 239 | + | "push", |
|
| 240 | + | &me.account.username, |
|
| 241 | + | serde_json::json!({ "updates": [{ "reference": bookmark, "old_state": oxy_core::hash::ObjectRef::ZERO.to_hex(), "new_state": new_state, "heads": heads.len(), "changes": 0, "created": true }], "via": "web" }), |
|
| 242 | + | ) |
|
| 243 | + | .await; |
|
| 244 | + | db::accounts::audit(&app.db, Some(&me.account), "bookmark.created", &format!("{owner}/{name}"), &format!("{bookmark} from {from}")) |
|
| 245 | + | .await |
|
| 246 | + | .ok(); |
|
| 247 | + | let notice = format!("Created {bookmark} from {from}. Propose changes into it, or make it the default in Settings."); |
|
| 248 | + | bookmarks_page(&app, shell, repo, row, role, Some(notice), None).await |
|
| 249 | + | } |
|
| 250 | + | ||
| 144 | 251 | #[derive(Template)] |
|
| 145 | 252 | #[template(path = "tags.html")] |
|
| 146 | 253 | pub struct TagsPage { |
|
| … | 178 unchanged lines |
modified crates/hub/src/web/repo_settings.rs +19 −1
| … | 523 unchanged lines |
||
| 524 | 524 | Form(f): Form<GeneralForm>, |
|
| 525 | 525 | ) -> Result<Response, Error> { |
|
| 526 | 526 | let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer); |
|
| 527 | - | let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?; |
|
| 527 | + | let (repo, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?; |
|
| 528 | 528 | csrf(&viewer, &f.csrf_token, &shell)?; |
|
| 529 | 529 | let e = |e| Error::from_db(shell.clone(), e); |
|
| 530 | + | // A new default must be a bookmark the repository has, or every page |
|
| 531 | + | // that opens it finds nothing; an empty repository may name any, for |
|
| 532 | + | // its first push to create. |
|
| 533 | + | let wanted = f.default_bookmark.trim().to_string(); |
|
| 534 | + | if wanted != row.default_bookmark { |
|
| 535 | + | let names: Vec<String> = app |
|
| 536 | + | .repos |
|
| 537 | + | .read(&repo, |r, _| Ok(r.view()?.bookmarks.keys().cloned().collect::<Vec<String>>())) |
|
| 538 | + | .await |
|
| 539 | + | .map_err(|err| Error::from_repo(shell.clone(), err))?; |
|
| 540 | + | if !names.is_empty() && !names.contains(&wanted) { |
|
| 541 | + | let mut some: Vec<&str> = names.iter().map(String::as_str).filter(|n| *n == "main" || *n == "master").collect(); |
|
| 542 | + | some.extend(names.iter().map(String::as_str).filter(|n| *n != "main" && *n != "master").take(8 - some.len())); |
|
| 543 | + | let more = if names.len() > some.len() { format!(" and {} more", names.len() - some.len()) } else { String::new() }; |
|
| 544 | + | let msg = format!("{wanted} is not a bookmark here yet: push it first, or choose one of {}{more}.", some.join(", ")); |
|
| 545 | + | return back(&row, Section::General, None, Some(msg)); |
|
| 546 | + | } |
|
| 547 | + | } |
|
| 530 | 548 | let topics = match db::repos::parse_topics(&f.topics) { |
|
| 531 | 549 | Ok(t) => t, |
|
| 532 | 550 | Err(msg) => return back(&row, Section::General, None, Some(msg)), |
|
| … | 1113 unchanged lines |
modified crates/hub/tests/releases.rs +94 −0
| … | 481 unchanged lines |
||
| 482 | 482 | let r = form(app, "/andy/demo/tags/create", &andy, &format!("_csrf={csrf}&name=v0.2")).await; |
|
| 483 | 483 | assert!(r.text().contains("withdrawn"), "{}", r.text()); |
|
| 484 | 484 | } |
|
| 485 | + | ||
| 486 | + | /// A bookmark is made on the web from another (doomsdayone/tetriblox could |
|
| 487 | + | /// not make develop), a default must be a bookmark the repository has, and |
|
| 488 | + | /// one that is missing (set before) leaves the overview showing main with a |
|
| 489 | + | /// note rather than an empty repository or a 404 saying "not a change". |
|
| 490 | + | #[tokio::test(flavor = "multi_thread")] |
|
| 491 | + | async fn bookmarks_are_made_on_the_web_and_a_default_must_exist() { |
|
| 492 | + | hermetic(); |
|
| 493 | + | let dir = tempfile::tempdir().unwrap(); |
|
| 494 | + | let mut config = Config::load(dir.path()).unwrap(); |
|
| 495 | + | config.registration.setup_code = "test-setup".into(); |
|
| 496 | + | config.auth.push_tokens = vec!["site".into()]; |
|
| 497 | + | config.registration.mode = "open".into(); |
|
| 498 | + | let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); |
|
| 499 | + | let port = listener.local_addr().unwrap().port(); |
|
| 500 | + | config.server.base_url = format!("http://127.0.0.1:{port}"); |
|
| 501 | + | let app = oxygenhub::build(config).await.unwrap(); |
|
| 502 | + | let router = oxygenhub::router(app.clone()); |
|
| 503 | + | tokio::spawn(async move { axum::serve(listener, router).await.unwrap() }); |
|
| 504 | + | let base = format!("http://127.0.0.1:{port}"); |
|
| 505 | + | let app = &app; |
|
| 506 | + | let andy = send( |
|
| 507 | + | app, |
|
| 508 | + | "POST", |
|
| 509 | + | "/register", |
|
| 510 | + | None, |
|
| 511 | + | None, |
|
| 512 | + | Some("application/x-www-form-urlencoded"), |
|
| 513 | + | b"username=andy&email=a%40example.com&password=correct-horse-battery&setup=test-setup".to_vec(), |
|
| 514 | + | ) |
|
| 515 | + | .await |
|
| 516 | + | .cookie |
|
| 517 | + | .unwrap(); |
|
| 518 | + | let repo = RepoName::parse("andy/demo").unwrap(); |
|
| 519 | + | app.repos.create(&repo, None).unwrap(); |
|
| 520 | + | db::repos::create(&app.db, "andy", "demo", "", hub_api::Visibility::Public).await.unwrap(); |
|
| 521 | + | let work = tempfile::tempdir().unwrap(); |
|
| 522 | + | let mut client = Repo::init(work.path()).unwrap(); |
|
| 523 | + | client.create_identity("Andy", "[email protected]", IdentityScope::Repo).unwrap(); |
|
| 524 | + | client.config.set("remote.origin.url", &format!("{base}/andy/demo")).unwrap(); |
|
| 525 | + | client.config.set("remote.origin.token", "site").unwrap(); |
|
| 526 | + | client.save_config().unwrap(); |
|
| 527 | + | write(work.path(), "a.txt", b"one\n"); |
|
| 528 | + | client.snapshot("t").unwrap(); |
|
| 529 | + | let c1 = client.commit("First", &[]).unwrap(); |
|
| 530 | + | assert!(client.push("origin", false).unwrap().iter().all(|r| r.ok)); |
|
| 531 | + | let host = || app.repos.open(&repo).unwrap(); |
|
| 532 | + | let heads_of = |b: &str| { |
|
| 533 | + | let r = host(); |
|
| 534 | + | let st = r.view().unwrap().bookmarks.get(b).copied(); |
|
| 535 | + | st.map(|st| r.store().get_state(&st).unwrap().heads) |
|
| 536 | + | }; |
|
| 537 | + | let page = send(app, "GET", "/andy/demo/bookmarks", Some(&andy), None, None, vec![]).await.text(); |
|
| 538 | + | assert!(page.contains("New bookmark") && page.contains("value=\"main\""), "{page}"); |
|
| 539 | + | let csrf = csrf_of(&page); |
|
| 540 | + | ||
| 541 | + | // develop from the default; then not twice, nor from nothing. |
|
| 542 | + | let r = form(app, "/andy/demo/bookmarks/create", &andy, &format!("_csrf={csrf}&name=develop&from=")).await; |
|
| 543 | + | assert!(r.text().contains("Created develop from main."), "{}", r.text()); |
|
| 544 | + | assert_eq!(heads_of("develop"), Some(vec![c1])); |
|
| 545 | + | let r = form(app, "/andy/demo/bookmarks/create", &andy, &format!("_csrf={csrf}&name=develop&from=main")).await; |
|
| 546 | + | assert!(r.text().contains("develop is a bookmark already."), "{}", r.text()); |
|
| 547 | + | let r = form(app, "/andy/demo/bookmarks/create", &andy, &format!("_csrf={csrf}&name=feature&from=nowhere")).await; |
|
| 548 | + | assert!(r.text().contains(""nowhere" is not a bookmark, tag or change of this repository"), "{}", r.text()); |
|
| 549 | + | assert_eq!(heads_of("feature"), None); |
|
| 550 | + | let r = form(app, "/andy/demo/bookmarks/create", &andy, &format!("_csrf={csrf}&name=two+words&from=main")).await; |
|
| 551 | + | assert!(r.text().contains("no spaces"), "{}", r.text()); |
|
| 552 | + | ||
| 553 | + | // The default: an existing bookmark, never a missing one. |
|
| 554 | + | let settings = |b: &str| format!("_csrf={csrf}&description=&visibility=public&default_bookmark={b}"); |
|
| 555 | + | let r = form(app, "/andy/demo/settings", &andy, &settings("release")).await; |
|
| 556 | + | let to = r.location.unwrap_or_default(); |
|
| 557 | + | assert!(to.contains("error=") && to.contains("release"), "{to}"); |
|
| 558 | + | let row = || async { db::repos::get(&app.db, "andy", "demo").await.unwrap().unwrap() }; |
|
| 559 | + | assert_eq!(row().await.default_bookmark, "main"); |
|
| 560 | + | form(app, "/andy/demo/settings", &andy, &settings("develop")).await; |
|
| 561 | + | assert_eq!(row().await.default_bookmark, "develop"); |
|
| 562 | + | ||
| 563 | + | // One set before it existed (or deleted since): main, with a note. |
|
| 564 | + | sqlx::query("UPDATE repositories SET default_bookmark = 'gone' WHERE name = 'demo'").execute(&app.db).await.unwrap(); |
|
| 565 | + | let page = send(app, "GET", "/andy/demo", Some(&andy), None, None, vec![]).await; |
|
| 566 | + | assert_eq!(page.status, StatusCode::OK); |
|
| 567 | + | let page = page.text(); |
|
| 568 | + | assert!( |
|
| 569 | + | page.contains("This repository has no bookmark <strong>gone</strong>, its default, so this is <strong>main</strong>."), |
|
| 570 | + | "{page}" |
|
| 571 | + | ); |
|
| 572 | + | assert!(page.contains("a.txt"), "main's files: {page}"); |
|
| 573 | + | // Saving other settings keeps a missing default rather than refusing. |
|
| 574 | + | let r = |
|
| 575 | + | form(app, "/andy/demo/settings", &andy, &format!("_csrf={csrf}&description=Demo&visibility=public&default_bookmark=gone")).await; |
|
| 576 | + | assert!(!r.location.unwrap_or_default().contains("error=")); |
|
| 577 | + | assert_eq!(row().await.description, "Demo"); |
|
| 578 | + | } |
modified templates/bookmarks.html +8 −0
| … | 6 unchanged lines |
||
| 7 | 7 | <div class="head"><h1>Bookmarks <small>{{ bookmarks.len() }}</small></h1></div> |
|
| 8 | 8 | {% if let Some(n) = notice %}<p class="note">{{ n }}</p>{% endif %} |
|
| 9 | 9 | {% if let Some(e) = error %}<p class="error">{{ e }}</p>{% endif %} |
|
| 10 | + | {% if can_delete %}<details class="panel"{% if bookmarks.is_empty() %} open{% endif %}><summary>New bookmark</summary> |
|
| 11 | + | <form class="card" method="post" action="{{ repo.url }}/bookmarks/create"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"> |
|
| 12 | + | <label for="bm-name">Name</label><input type="text" id="bm-name" name="name" maxlength="255" placeholder="develop" required> |
|
| 13 | + | <label for="bm-from">From</label><input type="text" id="bm-from" name="from" list="bm-list" value="{% for b in bookmarks %}{% if b.default %}{{ b.name }}{% endif %}{% endfor %}" placeholder="a bookmark, tag or change"> |
|
| 14 | + | <datalist id="bm-list">{% for b in bookmarks %}<option value="{{ b.name }}">{% endfor %}</datalist> |
|
| 15 | + | <p class="small mute">It starts at the same changes as what it is made from; nothing is copied.</p> |
|
| 16 | + | <button type="submit">CREATE</button> |
|
| 17 | + | </form></details>{% endif %} |
|
| 10 | 18 | <div class="table"><table> |
|
| 11 | 19 | <tr><th>Bookmark</th><th>Heads</th><th>Changes</th><th>Latest</th><th></th></tr> |
|
| 12 | 20 | {% for b in bookmarks %} |
|
| … | 11 unchanged lines |
modified templates/overview.html +1 −0
| … | 9 unchanged lines |
||
| 10 | 10 | <div> |
|
| 11 | 11 | {% if o.heads > 1 %}<div class="strip warn">{% if at.len() > 24 %}<span class="hash-tip" tabindex="0" data-full="{{ at }}" aria-label="{{ at }}">{{ at|ends }}</span>{% else %}{{ at }}{% endif %} has {{ o.heads }} heads; the tree below is their union. <a href="{{ repo.url }}/bookmarks">Bookmarks</a></div>{% endif %} |
|
| 12 | 12 | {% if o.conflicted %}<div class="strip bad">This state carries conflicts. <a href="{{ repo.url }}/conflicts">See them</a></div>{% endif %} |
|
| 13 | + | {% if let Some(b) = o.standing_in %}<div class="strip warn">This repository has no bookmark <strong>{{ repo.default_bookmark }}</strong>, its default, so this is <strong>{{ b }}</strong>. Push {{ repo.default_bookmark }}, or choose an existing default in <a href="{{ repo.url }}/settings">settings</a>.</div>{% endif %} |
|
| 13 | 14 | <div class="bar"> |
|
| 14 | 15 | <details class="ref-pick"> |
|
| 15 | 16 | <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> {% if at.len() > 24 %}<span class="hash-tip" tabindex="0" data-full="{{ at }}" aria-label="{{ at }}">{{ at|ends }}</span>{% else %}{{ at }}{% endif %} <span aria-hidden="true">▾</span></summary> |
|
| … | 103 unchanged lines |