Proposal page: centred timeline with avatars, inline editing, labels and projects a1b9869565aa
Asked for by Andy. The conversation is centred at the normal width (the diff tabs stay wide) and is one timeline in time order: the description, comments, reviews and events, each beside its author's avatar. The title, target and description are edited inline from an Edit button, not the sidebar; ready/draft/close/reopen sit under the comment box and show only when they apply, which also removes the empty box a landed proposal had. Labels are editable, or point at the labels page when there are none; proposals and issues can be added to and taken off the owner's projects. Every proposal action redirects back, so a reload never repeats it. closes #53
- Author
- Andy McCall @andymccall <[email protected]>
- When
- · 2026-09-29 16:52 UTC
- Signature
- ✔ signed by Andy McCall
- Hash
a1b9869565aad8c8b391208f64eba146178677c739c7374a95f0b2be8f44723d- Depends on
- 145306044896 18e381ea88c2 1f1eb06a9009 38833ed78707 78001759604c 9198231cee96 93be9ce4653e 9df855f29948 9e5ef6c23006 b64e2537d702 d23464c0ac60 de505df5cf8d e1ec9fbf538e fd958fbc06fb
- Depended on by
- 0bc95135954d 33d98147511b 2a8b621676b4 b5a0a69b3622 ffb67a81188c 6ae3fa0af4f6 206c1fc1b06a 7d2eb590051f 9e50caedf4db 4f37afcc3583 8e96499f2705 cb71e18eb6f9 0b20f1ec4007
- In bookmarks
- api-unknown-fields cancel-landed-runs clear-waiting close-proposal-140 diffusion-pin-bookmark draft-ready image-check job-waits main oh-comment-body-file proposal-list-fast run-one-job wiki-says-so
- In tags
- v0.1.0 v0.2.0 v0.3.0 v0.3.1 v0.4.0 v0.4.1 v0.4.2 v0.5.0 v0.5.1 v0.5.2 v0.6.0 v0.6.1 v0.7.0 v0.8.0 v0.9.0 v0.9.1 v0.9.11 v0.9.15 v0.9.16 v0.9.19 v0.9.2 v0.9.20 v0.9.21 v0.9.22 v0.9.23 v0.9.24 v0.9.3 v0.9.4 v0.9.5 v0.9.6 v0.9.7 v0.9.8 v0.9.9
- Edits
- 59 atoms, 0 tree operations, 11 files
Browse the tree at this change
modified crates/hub/src/web/issues.rs +4 −0
| … | 316 unchanged lines |
||
| 317 | 317 | projects: Vec<(crate::db::projects::Project, String)>, |
|
| 318 | 318 | /// Proposals whose description closes it. |
|
| 319 | 319 | closed_by: Vec<super::chain::Link>, |
|
| 320 | + | /// Projects the viewer may add it to. |
|
| 321 | + | project_choices: Option<Vec<crate::db::projects::Project>>, |
|
| 320 | 322 | notice: Option<String>, |
|
| 321 | 323 | error: Option<String>, |
|
| 322 | 324 | } |
|
| … | 51 unchanged lines |
||
| 374 | 376 | let can_edit = me.is_some_and(|a| Some(a.id) == i.author_id) || can_triage; |
|
| 375 | 377 | let projects = super::projects::placements_for(app, viewer, &row, i.number).await; |
|
| 376 | 378 | let closed_by = super::chain::proposals_closing(app, &row, i.number).await; |
|
| 379 | + | let project_choices = super::projects::project_choices(app, viewer, &row, &projects).await; |
|
| 377 | 380 | Ok(Page(IssuePage { |
|
| 378 | 381 | shell, |
|
| 379 | 382 | repo: RepoShell::new(app, &row, "issues").with_role(role), |
|
| 380 | 383 | i, |
|
| 381 | 384 | closed_by, |
|
| 385 | + | project_choices, |
|
| 382 | 386 | body_html, |
|
| 383 | 387 | timeline: timeline.into_iter().map(|(_, it)| it).collect(), |
|
| 384 | 388 | comments, |
|
| … | 625 unchanged lines |
modified crates/hub/src/web/mod.rs +2 −0
| … | 141 unchanged lines |
||
| 142 | 142 | .route("/{owner}/{name}/proposal/{number}/state", post(proposals::state)) |
|
| 143 | 143 | .route("/{owner}/{name}/proposal/{number}/land", post(proposals::land)) |
|
| 144 | 144 | .route("/{owner}/{name}/proposal/{number}/labels", post(proposals::set_labels)) |
|
| 145 | + | .route("/{owner}/{name}/proposal/{number}/project", post(projects::place_proposal)) |
|
| 146 | + | .route("/{owner}/{name}/issue/{number}/project", post(projects::place_issue)) |
|
| 145 | 147 | .route("/{owner}/{name}/proposal/{number}/events", get(proposals::events)) |
|
| 146 | 148 | .route("/{owner}/{name}/settings/hooks", post(repo_settings::hooks)) |
|
| 147 | 149 | .route("/{owner}/{name}/settings/hooks/{id}/deliveries", get(repo_settings::deliveries)) |
|
| … | 454 unchanged lines |
modified crates/hub/src/web/projects.rs +103 −0
| … | 456 unchanged lines |
||
| 457 | 457 | assert_eq!(parse_reference("hub", ""), None); |
|
| 458 | 458 | } |
|
| 459 | 459 | } |
|
| 460 | + | ||
| 461 | + | #[derive(Deserialize)] |
|
| 462 | + | pub struct PlaceForm { |
|
| 463 | + | #[serde(rename = "_csrf")] |
|
| 464 | + | csrf_token: String, |
|
| 465 | + | /// The project's number among its owner's. |
|
| 466 | + | project: i64, |
|
| 467 | + | /// `add` or `remove`. |
|
| 468 | + | action: String, |
|
| 469 | + | } |
|
| 470 | + | ||
| 471 | + | /// Put an issue or proposal on one of its repository owner's projects (in |
|
| 472 | + | /// the first column), or take it off, from its own page. |
|
| 473 | + | async fn place( |
|
| 474 | + | app: Shared, |
|
| 475 | + | viewer: Viewer, |
|
| 476 | + | owner: String, |
|
| 477 | + | name: String, |
|
| 478 | + | kind: &'static str, |
|
| 479 | + | number: i64, |
|
| 480 | + | f: PlaceForm, |
|
| 481 | + | ) -> Result<Response, Error> { |
|
| 482 | + | let page = format!("/{owner}/{name}/{kind}/{number}"); |
|
| 483 | + | let shell = Shell::new(&app, &page, &viewer); |
|
| 484 | + | csrf(&viewer, &f.csrf_token, &shell)?; |
|
| 485 | + | let (_, row, _) = super::lookup(&app, &shell, &viewer, &owner, &name).await?; |
|
| 486 | + | let r = rights(&app, &shell, &viewer, &row.owner).await?; |
|
| 487 | + | if !r.edit { |
|
| 488 | + | return Err(Error::not_found(shell)); |
|
| 489 | + | } |
|
| 490 | + | let e = |e| Error::from_db(shell.clone(), e); |
|
| 491 | + | let project = pdb::get(&app.db, &row.owner, f.project).await.map_err(e)?.ok_or_else(|| Error::not_found(shell.clone()))?; |
|
| 492 | + | let actor = viewer.account().map(|a| a.username.clone()).unwrap_or_default(); |
|
| 493 | + | let mut error = None; |
|
| 494 | + | match f.action.as_str() { |
|
| 495 | + | "add" => { |
|
| 496 | + | let columns = pdb::columns(&app.db, project.id).await.map_err(e)?; |
|
| 497 | + | let Some(first) = columns.first() else { return Err(Error::bad(shell, "that project has no columns")) }; |
|
| 498 | + | if let Err(msg) = |
|
| 499 | + | add_card(&app, viewer.account(), &row.owner, project.id, first.id, &format!("{}#{number}", row.name), &row.name, "", &actor) |
|
| 500 | + | .await |
|
| 501 | + | .map_err(e)? |
|
| 502 | + | { |
|
| 503 | + | error = Some(msg); |
|
| 504 | + | } |
|
| 505 | + | } |
|
| 506 | + | "remove" => { |
|
| 507 | + | for c in pdb::cards(&app.db, project.id).await.map_err(e)? { |
|
| 508 | + | if c.repo_id == Some(row.id) && c.number == Some(number) { |
|
| 509 | + | pdb::remove_card(&app.db, project.id, c.id).await.map_err(e)?; |
|
| 510 | + | } |
|
| 511 | + | } |
|
| 512 | + | } |
|
| 513 | + | _ => return Err(Error::bad(shell, "add or remove")), |
|
| 514 | + | } |
|
| 515 | + | let to = match error { |
|
| 516 | + | Some(m) => format!("{page}?error={}", super::settings::urlencode(&m)), |
|
| 517 | + | None => page, |
|
| 518 | + | }; |
|
| 519 | + | Ok(Redirect::to(&to).into_response()) |
|
| 520 | + | } |
|
| 521 | + | ||
| 522 | + | /// `POST /{owner}/{name}/proposal/{number}/project`. |
|
| 523 | + | pub async fn place_proposal( |
|
| 524 | + | State(app): State<Shared>, |
|
| 525 | + | viewer: Viewer, |
|
| 526 | + | Path((owner, name, number)): Path<(String, String, i64)>, |
|
| 527 | + | Form(f): Form<PlaceForm>, |
|
| 528 | + | ) -> Result<Response, Error> { |
|
| 529 | + | place(app, viewer, owner, name, "proposal", number, f).await |
|
| 530 | + | } |
|
| 531 | + | ||
| 532 | + | /// `POST /{owner}/{name}/issue/{number}/project`. |
|
| 533 | + | pub async fn place_issue( |
|
| 534 | + | State(app): State<Shared>, |
|
| 535 | + | viewer: Viewer, |
|
| 536 | + | Path((owner, name, number)): Path<(String, String, i64)>, |
|
| 537 | + | Form(f): Form<PlaceForm>, |
|
| 538 | + | ) -> Result<Response, Error> { |
|
| 539 | + | place(app, viewer, owner, name, "issue", number, f).await |
|
| 540 | + | } |
|
| 541 | + | ||
| 542 | + | /// The owner's open projects `viewer` may add an item to that it is not on |
|
| 543 | + | /// yet; `None` when the viewer may not change the owner's projects at all. |
|
| 544 | + | pub async fn project_choices( |
|
| 545 | + | app: &Shared, |
|
| 546 | + | viewer: &Viewer, |
|
| 547 | + | repo: &db::repos::RepoRow, |
|
| 548 | + | placed: &[(pdb::Project, String)], |
|
| 549 | + | ) -> Option<Vec<pdb::Project>> { |
|
| 550 | + | let Ok(Some(r)) = rights_of(app, viewer.account(), &repo.owner).await else { return None }; |
|
| 551 | + | if !r.edit { |
|
| 552 | + | return None; |
|
| 553 | + | } |
|
| 554 | + | Some( |
|
| 555 | + | pdb::of_owner(&app.db, &repo.owner) |
|
| 556 | + | .await |
|
| 557 | + | .unwrap_or_default() |
|
| 558 | + | .into_iter() |
|
| 559 | + | .filter(|p| !p.closed && !placed.iter().any(|(q, _)| q.id == p.id)) |
|
| 560 | + | .collect(), |
|
| 561 | + | ) |
|
| 562 | + | } |
modified crates/hub/src/web/proposals.rs +68 −40
| … | 347 unchanged lines |
||
| 348 | 348 | pub line: Option<usize>, |
|
| 349 | 349 | } |
|
| 350 | 350 | ||
| 351 | + | /// One entry of a proposal's conversation, in time order: what kind it |
|
| 352 | + | /// is and its index in `comments`, `reviews` or `events`. |
|
| 353 | + | pub struct Entry { |
|
| 354 | + | pub kind: &'static str, |
|
| 355 | + | pub index: usize, |
|
| 356 | + | } |
|
| 357 | + | ||
| 351 | 358 | pub struct ReviewView { |
|
| 352 | 359 | pub row: pdb::ReviewRow, |
|
| 353 | 360 | pub body_html: String, |
|
| … | 194 unchanged lines |
||
| 548 | 555 | p: pdb::ProposalRow, |
|
| 549 | 556 | /// Issues it closes, from its description and its changes. |
|
| 550 | 557 | closes: Vec<super::chain::Link>, |
|
| 558 | + | /// Projects the viewer may add it to. |
|
| 559 | + | project_choices: Option<Vec<db::projects::Project>>, |
|
| 560 | + | /// Comments (not anchored to a line), reviews and events, oldest first. |
|
| 561 | + | timeline: Vec<Entry>, |
|
| 562 | + | /// The viewer may set labels (triage or above). |
|
| 563 | + | can_triage: bool, |
|
| 551 | 564 | body_html: String, |
|
| 552 | 565 | tab: String, |
|
| 553 | 566 | changes: Vec<ChangeView>, |
|
| … | 36 unchanged lines |
||
| 590 | 603 | #[derive(Deserialize)] |
|
| 591 | 604 | pub struct DetailQuery { |
|
| 592 | 605 | tab: Option<String>, |
|
| 606 | + | /// What an action said, after it redirected here. |
|
| 593 | 607 | #[serde(default)] |
|
| 608 | + | notice: Option<String>, |
|
| 609 | + | #[serde(default)] |
|
| 610 | + | error: Option<String>, |
|
| 611 | + | #[serde(default)] |
|
| 594 | 612 | w: Option<String>, |
|
| 595 | 613 | } |
|
| 596 | 614 | ||
| … | 74 unchanged lines |
||
| 671 | 689 | let projects = super::projects::placements_for(app, viewer, &row, p.number).await; |
|
| 672 | 690 | let texts: Vec<String> = std::iter::once(p.body.clone()).chain(changes.iter().map(|c| format!("{}\n{}", c.title, c.body))).collect(); |
|
| 673 | 691 | let closes = super::chain::issues_closed_by(app, &row, &texts.iter().map(String::as_str).collect::<Vec<_>>()).await; |
|
| 692 | + | let project_choices = super::projects::project_choices(app, viewer, &row, &projects).await; |
|
| 693 | + | let mut dated: Vec<(i64, Entry)> = Vec::new(); |
|
| 694 | + | for (index, c) in comments.iter().enumerate() { |
|
| 695 | + | if c.row.anchor_change.is_empty() { |
|
| 696 | + | dated.push((c.row.created_ms, Entry { kind: "comment", index })); |
|
| 697 | + | } |
|
| 698 | + | } |
|
| 699 | + | for (index, r) in v.reviews.iter().enumerate() { |
|
| 700 | + | dated.push((r.row.created_ms, Entry { kind: "review", index })); |
|
| 701 | + | } |
|
| 702 | + | for (index, e) in events.iter().enumerate() { |
|
| 703 | + | dated.push((e.at_ms, Entry { kind: "event", index })); |
|
| 704 | + | } |
|
| 705 | + | dated.sort_by_key(|(at, _)| *at); |
|
| 706 | + | let timeline: Vec<Entry> = dated.into_iter().map(|(_, e)| e).collect(); |
|
| 674 | 707 | Ok(Page(ProposalPage { |
|
| 708 | + | can_triage: role >= Role::Triage, |
|
| 709 | + | timeline, |
|
| 675 | 710 | closes, |
|
| 711 | + | project_choices, |
|
| 676 | 712 | projects, |
|
| 677 | 713 | shell, |
|
| 678 | 714 | repo: RepoShell::new(app, &row, "proposals").with_role(role), |
|
| … | 34 unchanged lines |
||
| 713 | 749 | })) |
|
| 714 | 750 | } |
|
| 715 | 751 | ||
| 752 | + | /// Back to the proposal after an action, with what to say: a redirect, so |
|
| 753 | + | /// reloading the page (the checks script does) never repeats the action. |
|
| 754 | + | fn back(owner: &str, name: &str, number: i64, tab: &str, notice: Option<String>, error: Option<String>) -> Response { |
|
| 755 | + | let mut url = format!("/{owner}/{name}/proposal/{number}?tab={tab}"); |
|
| 756 | + | if let Some(n) = notice { |
|
| 757 | + | url.push_str(&format!("¬ice={}", super::settings::urlencode(&n))); |
|
| 758 | + | } |
|
| 759 | + | if let Some(e) = error { |
|
| 760 | + | url.push_str(&format!("&error={}", super::settings::urlencode(&e))); |
|
| 761 | + | } |
|
| 762 | + | Redirect::to(&url).into_response() |
|
| 763 | + | } |
|
| 764 | + | ||
| 716 | 765 | pub async fn show( |
|
| 717 | 766 | State(app): State<Shared>, |
|
| 718 | 767 | viewer: Viewer, |
|
| … | 1 unchanged lines |
||
| 720 | 769 | Query(q): Query<DetailQuery>, |
|
| 721 | 770 | ) -> Result<Page<ProposalPage>, Error> { |
|
| 722 | 771 | let shell = Shell::new(&app, &format!("/{owner}/{name}/proposal/{number}"), &viewer); |
|
| 723 | - | let mut page = detail(&app, shell, &viewer, &owner, &name, number, q.tab.unwrap_or_else(|| "conversation".into()), None, None).await?; |
|
| 772 | + | let mut page = |
|
| 773 | + | detail(&app, shell, &viewer, &owner, &name, number, q.tab.unwrap_or_else(|| "conversation".into()), q.notice, q.error).await?; |
|
| 724 | 774 | if q.w.as_deref() == Some("1") { |
|
| 725 | 775 | // Hide whitespace-only changes: the same hunks, rendered again. |
|
| 726 | 776 | page.0.files = |
|
| … | 21 unchanged lines |
||
| 748 | 798 | viewer: Viewer, |
|
| 749 | 799 | Path((owner, name, number)): Path<(String, String, i64)>, |
|
| 750 | 800 | Form(f): Form<CommentForm>, |
|
| 751 | - | ) -> Result<Page<ProposalPage>, Error> { |
|
| 801 | + | ) -> Result<Response, Error> { |
|
| 752 | 802 | let shell = Shell::new(&app, &format!("/{owner}/{name}/proposal/{number}"), &viewer); |
|
| 753 | 803 | let me = require(&viewer, &shell)?; |
|
| 754 | 804 | csrf(&viewer, &f.csrf_token, &shell)?; |
|
| … | 3 unchanged lines |
||
| 758 | 808 | .map_err(|e| Error::from_db(shell.clone(), e))? |
|
| 759 | 809 | .ok_or_else(|| Error::not_found(shell.clone()))?; |
|
| 760 | 810 | if f.body.trim().is_empty() { |
|
| 761 | - | return detail( |
|
| 762 | - | &app, |
|
| 763 | - | shell, |
|
| 764 | - | &viewer, |
|
| 765 | - | &owner, |
|
| 766 | - | &name, |
|
| 767 | - | number, |
|
| 768 | - | "conversation".into(), |
|
| 769 | - | None, |
|
| 770 | - | Some("An empty comment says nothing.".into()), |
|
| 771 | - | ) |
|
| 772 | - | .await; |
|
| 811 | + | return Ok(back(&owner, &name, number, "conversation", None, Some("An empty comment says nothing.".into()))); |
|
| 773 | 812 | } |
|
| 774 | 813 | let anchor = match (f.path.trim(), f.line) { |
|
| 775 | 814 | ("", _) | (_, None) => None, |
|
| … | 14 unchanged lines |
||
| 790 | 829 | ) |
|
| 791 | 830 | .await |
|
| 792 | 831 | .map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 793 | - | detail(&app, shell, &viewer, &owner, &name, number, tab.into(), None, None).await |
|
| 832 | + | Ok(back(&owner, &name, number, tab, None, None)) |
|
| 794 | 833 | } |
|
| 795 | 834 | ||
| 796 | 835 | #[derive(Deserialize)] |
|
| … | 10 unchanged lines |
||
| 807 | 846 | viewer: Viewer, |
|
| 808 | 847 | Path((owner, name, number)): Path<(String, String, i64)>, |
|
| 809 | 848 | Form(f): Form<ReviewForm>, |
|
| 810 | - | ) -> Result<Page<ProposalPage>, Error> { |
|
| 849 | + | ) -> Result<Response, Error> { |
|
| 811 | 850 | let shell = Shell::new(&app, &format!("/{owner}/{name}/proposal/{number}"), &viewer); |
|
| 812 | 851 | let me = require(&viewer, &shell)?; |
|
| 813 | 852 | csrf(&viewer, &f.csrf_token, &shell)?; |
|
| … | 6 unchanged lines |
||
| 820 | 859 | return Err(Error::bad(shell, "a review is comment, changes or approve")); |
|
| 821 | 860 | } |
|
| 822 | 861 | if f.verdict == "approve" && Some(me.account.id) == p.author_id { |
|
| 823 | - | return detail( |
|
| 824 | - | &app, |
|
| 825 | - | shell, |
|
| 826 | - | &viewer, |
|
| 827 | - | &owner, |
|
| 828 | - | &name, |
|
| 829 | - | number, |
|
| 830 | - | "conversation".into(), |
|
| 831 | - | None, |
|
| 832 | - | Some("You cannot approve your own proposal.".into()), |
|
| 833 | - | ) |
|
| 834 | - | .await; |
|
| 862 | + | return Ok(back(&owner, &name, number, "conversation", None, Some("You cannot approve your own proposal.".into()))); |
|
| 835 | 863 | } |
|
| 836 | 864 | pdb::add_review(&app.db, p.id, me.account.id, &me.account.username, &f.verdict, f.body.trim()) |
|
| 837 | 865 | .await |
|
| … | 8 unchanged lines |
||
| 846 | 874 | } else { |
|
| 847 | 875 | None |
|
| 848 | 876 | }; |
|
| 849 | - | detail(&app, shell, &viewer, &owner, &name, number, "conversation".into(), notice, None).await |
|
| 877 | + | Ok(back(&owner, &name, number, "conversation", notice, None)) |
|
| 850 | 878 | } |
|
| 851 | 879 | ||
| 852 | 880 | #[derive(Deserialize)] |
|
| … | 11 unchanged lines |
||
| 864 | 892 | viewer: Viewer, |
|
| 865 | 893 | Path((owner, name, number)): Path<(String, String, i64)>, |
|
| 866 | 894 | Form(f): Form<EditForm>, |
|
| 867 | - | ) -> Result<Page<ProposalPage>, Error> { |
|
| 895 | + | ) -> Result<Response, Error> { |
|
| 868 | 896 | let shell = Shell::new(&app, &format!("/{owner}/{name}/proposal/{number}"), &viewer); |
|
| 869 | 897 | let me = require(&viewer, &shell)?; |
|
| 870 | 898 | csrf(&viewer, &f.csrf_token, &shell)?; |
|
| … | 8 unchanged lines |
||
| 879 | 907 | let target = if crate::repos::names::valid_segment(f.target.trim()) { f.target.trim() } else { &p.target }; |
|
| 880 | 908 | pdb::edit(&app.db, p.id, f.title.trim(), f.body.trim(), target).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 881 | 909 | pdb::add_event(&app.db, p.id, &me.account.username, "edited", "").await.ok(); |
|
| 882 | - | detail(&app, shell, &viewer, &owner, &name, number, "conversation".into(), Some("Saved.".into()), None).await |
|
| 910 | + | Ok(back(&owner, &name, number, "conversation", Some("Saved.".into()), None)) |
|
| 883 | 911 | } |
|
| 884 | 912 | ||
| 885 | 913 | #[derive(Deserialize)] |
|
| … | 8 unchanged lines |
||
| 894 | 922 | viewer: Viewer, |
|
| 895 | 923 | Path((owner, name, number)): Path<(String, String, i64)>, |
|
| 896 | 924 | Form(f): Form<StateForm>, |
|
| 897 | - | ) -> Result<Page<ProposalPage>, Error> { |
|
| 925 | + | ) -> Result<Response, Error> { |
|
| 898 | 926 | let shell = Shell::new(&app, &format!("/{owner}/{name}/proposal/{number}"), &viewer); |
|
| 899 | 927 | let me = require(&viewer, &shell)?; |
|
| 900 | 928 | csrf(&viewer, &f.csrf_token, &shell)?; |
|
| … | 15 unchanged lines |
||
| 916 | 944 | pdb::set_state(&app.db, p.id, new_state, "").await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 917 | 945 | pdb::add_event(&app.db, p.id, &me.account.username, new_state, "").await.ok(); |
|
| 918 | 946 | crate::hooks::emit(&app, &row, &format!("proposal.{new_state}"), &me.account.username, serde_json::json!({ "number": number })).await; |
|
| 919 | - | detail(&app, shell, &viewer, &owner, &name, number, "conversation".into(), None, None).await |
|
| 947 | + | Ok(back(&owner, &name, number, "conversation", None, None)) |
|
| 920 | 948 | } |
|
| 921 | 949 | ||
| 922 | 950 | pub async fn resolve_comment( |
|
| … | 1 unchanged lines |
||
| 924 | 952 | viewer: Viewer, |
|
| 925 | 953 | Path((owner, name, number, id)): Path<(String, String, i64, i64)>, |
|
| 926 | 954 | Form(f): Form<super::session::CsrfForm>, |
|
| 927 | - | ) -> Result<Page<ProposalPage>, Error> { |
|
| 955 | + | ) -> Result<Response, Error> { |
|
| 928 | 956 | let shell = Shell::new(&app, &format!("/{owner}/{name}/proposal/{number}"), &viewer); |
|
| 929 | 957 | require(&viewer, &shell)?; |
|
| 930 | 958 | csrf(&viewer, &f.csrf, &shell)?; |
|
| … | 11 unchanged lines |
||
| 942 | 970 | if let Some(r) = current { |
|
| 943 | 971 | pdb::set_comment_resolved(&app.db, id, !r).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 944 | 972 | } |
|
| 945 | - | detail(&app, shell, &viewer, &owner, &name, number, "files".into(), None, None).await |
|
| 973 | + | Ok(back(&owner, &name, number, "files", None, None)) |
|
| 946 | 974 | } |
|
| 947 | 975 | ||
| 948 | 976 | /// Land from the hub: only when the repository has no trust policy (the |
|
| … | 3 unchanged lines |
||
| 952 | 980 | viewer: Viewer, |
|
| 953 | 981 | Path((owner, name, number)): Path<(String, String, i64)>, |
|
| 954 | 982 | Form(f): Form<super::session::CsrfForm>, |
|
| 955 | - | ) -> Result<Page<ProposalPage>, Error> { |
|
| 983 | + | ) -> Result<Response, Error> { |
|
| 956 | 984 | let shell = Shell::new(&app, &format!("/{owner}/{name}/proposal/{number}"), &viewer); |
|
| 957 | 985 | let me = require(&viewer, &shell)?; |
|
| 958 | 986 | csrf(&viewer, &f.csrf, &shell)?; |
|
| 959 | 987 | let (repo, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Write).await?; |
|
| 960 | 988 | match land_as(&app, &repo, &row, &me.account, number).await { |
|
| 961 | - | Ok(_) => detail(&app, shell, &viewer, &owner, &name, number, "conversation".into(), Some("Landed.".into()), None).await, |
|
| 989 | + | Ok(_) => Ok(back(&owner, &name, number, "conversation", Some("Landed.".into()), None)), |
|
| 962 | 990 | Err(Landing::NotFound) => Err(Error::not_found(shell)), |
|
| 963 | - | Err(Landing::Refused(m)) => detail(&app, shell, &viewer, &owner, &name, number, "conversation".into(), None, Some(m)).await, |
|
| 991 | + | Err(Landing::Refused(m)) => Ok(back(&owner, &name, number, "conversation", None, Some(m))), |
|
| 964 | 992 | } |
|
| 965 | 993 | } |
|
| 966 | 994 | ||
| … | 109 unchanged lines |
||
| 1076 | 1104 | viewer: Viewer, |
|
| 1077 | 1105 | Path((owner, name, number)): Path<(String, String, i64)>, |
|
| 1078 | 1106 | axum_extra::extract::Form(f): axum_extra::extract::Form<LabelsForm>, |
|
| 1079 | - | ) -> Result<Page<ProposalPage>, Error> { |
|
| 1107 | + | ) -> Result<Response, Error> { |
|
| 1080 | 1108 | let shell = Shell::new(&app, &format!("/{owner}/{name}/proposal/{number}"), &viewer); |
|
| 1081 | 1109 | require(&viewer, &shell)?; |
|
| 1082 | 1110 | csrf(&viewer, &f.csrf_token, &shell)?; |
|
| … | 8 unchanged lines |
||
| 1091 | 1119 | for l in db::issues::labels(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))? { |
|
| 1092 | 1120 | db::issues::set_proposal_label(&app.db, p.id, l.id, f.label.contains(&l.id)).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 1093 | 1121 | } |
|
| 1094 | - | detail(&app, shell, &viewer, &owner, &name, number, "conversation".into(), None, None).await |
|
| 1122 | + | Ok(back(&owner, &name, number, "conversation", None, None)) |
|
| 1095 | 1123 | } |
|
| 1096 | 1124 | ||
| 1097 | 1125 | /// `GET /{owner}/{name}/proposal/{number}/events`: server-sent events for |
|
| … | 91 unchanged lines |
modified crates/hub/tests/api.rs +19 −0
| … | 1107 unchanged lines |
||
| 1108 | 1108 | assert!(i.contains(">issue #1</small>")); |
|
| 1109 | 1109 | let chain = &i[i.find("class=\"chain\"").expect("a chain on the issue")..]; |
|
| 1110 | 1110 | assert!(chain.contains("closed by") && chain.contains("proposal #3</span> Faster, safer"), "{chain}"); |
|
| 1111 | + | // The conversation is a timeline with avatars; editing sits with the |
|
| 1112 | + | // content, not in the sidebar, and labels and projects are editable. |
|
| 1113 | + | let p = call(app, "GET", "/andy/demo/proposal/3", None, Some(&andy), None, None).await.body; |
|
| 1114 | + | assert!(p.contains("class=\"tl-avatar\" src=\"/avatar/"), "avatars"); |
|
| 1115 | + | assert!(p.contains("id=\"edit\"") && !p.contains("<h3>Edit</h3>"), "the editor is inline, not in the sidebar"); |
|
| 1116 | + | assert!(p.contains("no labels yet") && p.contains("/andy/demo/labels"), "a way to make labels"); |
|
| 1117 | + | let (st, _) = api(app, "POST", "/api/v1/projects/andy", Some("site"), Some(r#"{"title":"Board"}"#)).await; |
|
| 1118 | + | assert!(st.is_success(), "{st}"); |
|
| 1119 | + | let p = call(app, "GET", "/andy/demo/proposal/3", None, Some(&andy), None, None).await.body; |
|
| 1120 | + | assert!(p.contains("<option value=\"1\">Board</option>"), "the owner's projects to add to"); |
|
| 1121 | + | let csrf = csrf_of(&p); |
|
| 1122 | + | call(app, "POST", "/andy/demo/proposal/3/project", None, Some(&andy), None, Some(&format!("_csrf={csrf}&project=1&action=add"))).await; |
|
| 1123 | + | let p = call(app, "GET", "/andy/demo/proposal/3", None, Some(&andy), None, None).await.body; |
|
| 1124 | + | assert!(p.contains("/projects/andy/1\">Board</a>") && p.contains("value=\"remove\""), "on the project, removable"); |
|
| 1125 | + | call(app, "POST", "/andy/demo/proposal/3/project", None, Some(&andy), None, Some(&format!("_csrf={csrf}&project=1&action=remove"))) |
|
| 1126 | + | .await; |
|
| 1127 | + | let p = call(app, "GET", "/andy/demo/proposal/3", None, Some(&andy), None, None).await.body; |
|
| 1128 | + | assert!(!p.contains("/projects/andy/1\">Board</a>"), "taken off again"); |
|
| 1129 | + | ||
| 1111 | 1130 | // Closed by the proposal's change, not its description: still shown. |
|
| 1112 | 1131 | let two = call(app, "GET", "/andy/demo/issue/2", None, None, None, None).await.body; |
|
| 1113 | 1132 | let chain = &two[two.find("class=\"chain\"").expect("a chain on issue 2")..]; |
|
| … | 2 unchanged lines |
modified crates/hub/tests/edits.rs +7 −4
| … | 233 unchanged lines |
||
| 234 | 234 | let page = send(app, "GET", "/andy/demo/proposal/2", Some(&andy), None).await; |
|
| 235 | 235 | assert!(page.body.contains("ready to land") && page.body.contains(">LAND<"), "{}", page.body); |
|
| 236 | 236 | let r = send(app, "POST", "/andy/demo/proposal/2/land", Some(&andy), Some(&format!("_csrf={}", csrf_of(&page.body)))).await; |
|
| 237 | - | assert!(r.body.contains("Landed."), "{}", r.body); |
|
| 237 | + | // The action redirects back (so a reload never repeats it), saying so. |
|
| 238 | + | let back = r.location.clone().unwrap_or_default(); |
|
| 239 | + | assert!(back.starts_with("/andy/demo/proposal/2?") && back.contains("notice=Landed."), "{back}"); |
|
| 240 | + | assert!(send(app, "GET", &back, Some(&andy), None).await.body.contains("Landed.")); |
|
| 238 | 241 | assert_eq!(send(app, "GET", "/andy/demo/file/main/a.txt?plain=1", None, None).await.body, "ONE\ntwo\nthree\n"); |
|
| 239 | 242 | ||
| 240 | 243 | // A conflict: two proposals edit the same line from the same base; landing both conflicts main. |
|
| … | 19 unchanged lines |
||
| 260 | 263 | let page = send(app, "GET", &format!("/andy/demo/proposal/{n}"), Some(&andy), None).await; |
|
| 261 | 264 | let r = |
|
| 262 | 265 | send(app, "POST", &format!("/andy/demo/proposal/{n}/land"), Some(&andy), Some(&format!("_csrf={}", csrf_of(&page.body)))).await; |
|
| 263 | - | assert!(r.body.contains("Landed."), "#{n}: {}", r.body); |
|
| 266 | + | assert!(r.location.as_deref().is_some_and(|l| l.contains("notice=Landed.")), "#{n}: {:?}", r.location); |
|
| 264 | 267 | } |
|
| 265 | 268 | let conflicts = send(app, "GET", "/andy/demo/conflicts", Some(&andy), None).await.body; |
|
| 266 | 269 | assert!(conflicts.contains("resolve region by region") && conflicts.contains("/andy/demo/resolve/main/a.txt"), "{conflicts}"); |
|
| … | 102 unchanged lines |
||
| 369 | 372 | let page = send(app, "GET", &format!("/andy/demo/proposal/{n}"), Some(&andy), None).await; |
|
| 370 | 373 | let r = |
|
| 371 | 374 | send(app, "POST", &format!("/andy/demo/proposal/{n}/land"), Some(&andy), Some(&format!("_csrf={}", csrf_of(&page.body)))).await; |
|
| 372 | - | assert!(r.body.contains("Landed."), "#{n}: {}", r.body); |
|
| 375 | + | assert!(r.location.as_deref().is_some_and(|l| l.contains("notice=Landed.")), "#{n}: {:?} {}", r.location, r.body); |
|
| 373 | 376 | } |
|
| 374 | 377 | let conflicts = send(app, "GET", "/andy/demo/conflicts", Some(&andy), None).await.body; |
|
| 375 | 378 | assert!(conflicts.contains("choose which to keep") && conflicts.contains("/andy/demo/resolve/main/new.txt"), "{conflicts}"); |
|
| … | 50 unchanged lines |
||
| 426 | 429 | let page = send(app, "GET", &format!("/andy/demo/proposal/{n}"), Some(&andy), None).await; |
|
| 427 | 430 | let r = |
|
| 428 | 431 | send(app, "POST", &format!("/andy/demo/proposal/{n}/land"), Some(&andy), Some(&format!("_csrf={}", csrf_of(&page.body)))).await; |
|
| 429 | - | assert!(r.body.contains("Landed."), "#{n}: {}", r.body); |
|
| 432 | + | assert!(r.location.as_deref().is_some_and(|l| l.contains("notice=Landed.")), "#{n}: {:?} {}", r.location, r.body); |
|
| 430 | 433 | } |
|
| 431 | 434 | let conflicts = send(app, "GET", "/andy/demo/conflicts", Some(&andy), None).await.body; |
|
| 432 | 435 | assert!(conflicts.contains("replace (2 candidates)") && conflicts.contains("/andy/demo/resolve/main/img.bin"), "{conflicts}"); |
|
| … | 40 unchanged lines |
modified crates/hub/tests/proposals.rs +19 −6
| … | 91 unchanged lines |
||
| 92 | 92 | send(app, "POST", path, cookie, Some(form), None).await |
|
| 93 | 93 | } |
|
| 94 | 94 | ||
| 95 | + | /// POST a proposal action and follow its redirect back to the page, as a |
|
| 96 | + | /// browser does (actions redirect so a reload never repeats them). |
|
| 97 | + | async fn act(app: &Shared, path: &str, cookie: Option<&str>, form: &str) -> Res { |
|
| 98 | + | let mut req = Request::builder().method("POST").uri(path).header(header::CONTENT_TYPE, "application/x-www-form-urlencoded"); |
|
| 99 | + | if let Some(c) = cookie { |
|
| 100 | + | req = req.header(header::COOKIE, format!("oxyhub={c}")); |
|
| 101 | + | } |
|
| 102 | + | let res = oxygenhub::router(app.clone()).oneshot(req.body(Body::from(form.to_string())).unwrap()).await.unwrap(); |
|
| 103 | + | assert_eq!(res.status(), StatusCode::SEE_OTHER, "{path} redirects back"); |
|
| 104 | + | let to = res.headers().get(header::LOCATION).and_then(|v| v.to_str().ok()).unwrap_or("").to_string(); |
|
| 105 | + | get(app, &to, cookie).await |
|
| 106 | + | } |
|
| 107 | + | ||
| 95 | 108 | async fn register(app: &Shared, user: &str) -> String { |
|
| 96 | 109 | let r = post(app, "/register", None, &format!("username={user}&email={user}%40example.com&password=correct-horse-battery")).await; |
|
| 97 | 110 | assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body); |
|
| … | 65 unchanged lines |
||
| 163 | 176 | let files = get(app, "/andy/demo/proposal/1?tab=files", Some(&bob)).await; |
|
| 164 | 177 | assert!(files.body.contains("+three") || files.body.contains(">three<"), "diff shown: {}", files.body); |
|
| 165 | 178 | let csrf_b = csrf_of(&files.body); |
|
| 166 | - | let r = post(app, "/andy/demo/proposal/1/comment", Some(&bob), &format!("_csrf={csrf_b}&body=nice+line&path=a.txt&line=3")).await; |
|
| 179 | + | let r = act(app, "/andy/demo/proposal/1/comment", Some(&bob), &format!("_csrf={csrf_b}&body=nice+line&path=a.txt&line=3")).await; |
|
| 167 | 180 | assert_eq!(r.status, StatusCode::OK, "{}", r.body); |
|
| 168 | 181 | assert!(r.body.contains("class=\"thread\"") && r.body.contains("nice line"), "anchored comment placed: {}", r.body); |
|
| 169 | 182 | let c = db::proposals::comments(&app.db, 1).await.unwrap(); |
|
| … | 14 unchanged lines |
||
| 184 | 197 | ||
| 185 | 198 | // Bob cannot approve his own; Andy approves, which is pending until sealed. |
|
| 186 | 199 | let csrf_b = csrf_of(&get(app, "/andy/demo/proposal/1", Some(&bob)).await.body); |
|
| 187 | - | let r = post(app, "/andy/demo/proposal/1/review", Some(&bob), &format!("_csrf={csrf_b}&verdict=approve")).await; |
|
| 200 | + | let r = act(app, "/andy/demo/proposal/1/review", Some(&bob), &format!("_csrf={csrf_b}&verdict=approve")).await; |
|
| 188 | 201 | assert!(r.body.contains("cannot approve your own"), "{}", r.body); |
|
| 189 | - | let r = post(app, "/andy/demo/proposal/1/review", Some(&andy), &format!("_csrf={csrf_a}&verdict=approve&body=LGTM")).await; |
|
| 202 | + | let r = act(app, "/andy/demo/proposal/1/review", Some(&andy), &format!("_csrf={csrf_a}&verdict=approve&body=LGTM")).await; |
|
| 190 | 203 | assert!(r.body.contains("seal pending"), "{}", r.body); |
|
| 191 | 204 | assert!(r.body.contains("oxy seal --proposal")); |
|
| 192 | 205 | ||
| … | 26 unchanged lines |
||
| 219 | 232 | ||
| 220 | 233 | // Land from the hub (no policy): main moves to the projected heads. |
|
| 221 | 234 | let csrf_a = csrf_of(&page.body); |
|
| 222 | - | let r = post(app, "/andy/demo/proposal/1/land", Some(&andy), &format!("_csrf={csrf_a}")).await; |
|
| 235 | + | let r = act(app, "/andy/demo/proposal/1/land", Some(&andy), &format!("_csrf={csrf_a}")).await; |
|
| 223 | 236 | assert!(r.body.contains("Landed."), "{}", r.body); |
|
| 224 | 237 | let heads = app.repos.read(&repo, |r, _| Ok(r.bookmarks()?.into_iter().find(|b| b.name == "main").unwrap().heads)).await.unwrap(); |
|
| 225 | 238 | assert_eq!(heads, vec![feat]); |
|
| … | 38 unchanged lines |
||
| 264 | 277 | ); |
|
| 265 | 278 | // Closing and reopening. |
|
| 266 | 279 | let csrf_b = csrf_of(&get(app, "/andy/demo/proposal/2", Some(&bob)).await.body); |
|
| 267 | - | let r = post(app, "/andy/demo/proposal/2/state", Some(&bob), &format!("_csrf={csrf_b}&action=close")).await; |
|
| 280 | + | let r = act(app, "/andy/demo/proposal/2/state", Some(&bob), &format!("_csrf={csrf_b}&action=close")).await; |
|
| 268 | 281 | assert!(r.body.contains(">closed<"), "{}", r.body); |
|
| 269 | - | let r = post(app, "/andy/demo/proposal/2/state", Some(&bob), &format!("_csrf={csrf_b}&action=reopen")).await; |
|
| 282 | + | let r = act(app, "/andy/demo/proposal/2/state", Some(&bob), &format!("_csrf={csrf_b}&action=reopen")).await; |
|
| 270 | 283 | assert!(r.body.contains(">open<"), "{}", r.body); |
|
| 271 | 284 | } |
modified docs/spec.md +10 −0
| … | 375 unchanged lines |
||
| 376 | 376 | ||
| 377 | 377 | Per-repository numbered issues sharing the number space with proposals (as GitHub does; so every number is shown with its kind, "issue #1" or "proposal #2", in page headers and in the links `#N` becomes), with title, Markdown body, author, assignees, labels (name, colour, description), a milestone (title, due date, description, open/closed), state (`open`, `closed` with reason `completed` or `not planned`), comments, lock, and pin. Issue templates come from `.oxyhub/ISSUE_TEMPLATE/*.md` in the default state. |
|
| 378 | 378 | ||
| 379 | + | **The proposal page.** The conversation is centred at the normal page width; the changes and files tabs are wide for diffs. |
|
| 380 | + | - **Timeline:** the description, comments, reviews and events are one timeline in time order, each entry beside its author's avatar. |
|
| 381 | + | - **Editing:** the title, target and description are edited inline (an Edit button beside the title opens the editor below the timeline), not from the sidebar. |
|
| 382 | + | - **State actions:** ready for review, back to draft, close and reopen sit under the comment box, and only when they apply. |
|
| 383 | + | - **Sidebar:** |
|
| 384 | + | - Landing, Closes, Labels and Projects. |
|
| 385 | + | - Labels: those with triage edit them, or are pointed at the repository's labels page when it has none. |
|
| 386 | + | - Projects: editors of the owner's projects add the proposal to one (its first column) or take it off. Issues have the same project control. |
|
| 387 | + | - **Redirects:** every action redirects back to the page, so reloading it (the checks script does, when a check arrives) never repeats the action. |
|
| 388 | + | ||
| 379 | 389 | **The chain of work.** A proposal shows the issues it closes (`closes`, `fixes` or `resolves #N` in its description or in any of its changes' messages) in a strip under its title: each issue with its state, then the proposal, then its target bookmark, marked landed once it is. A Closes list sits in its sidebar. An issue shows the proposals that close it, by the same rule, with their states. So the path from a report to the change that landed it can be followed from either end. |
|
| 380 | 390 | ||
| 381 | 391 | Relative links and images in issue, comment and proposal text resolve against the repository's default bookmark, and in release notes against the release's tag. Cross references: `#12` links an issue or proposal; `owner/name#12` across repositories; a 12-hex-or-longer token links a change when it resolves; `closes #12` in a change message closes the issue when the change lands on the default bookmark ("closed by change"). Any other `#12` in a pushed change's message, on any bookmark, puts "referenced by change <hash> on <bookmark>" on that issue's or proposal's timeline, the hash linking to the change; other repositories' `owner/name#12` are left alone. |
|
| … | 255 unchanged lines |
modified templates/issue.html +2 −1
| … | 55 unchanged lines |
||
| 56 | 56 | {% if let Some(ms) = milestone %}<p><a href="{{ repo.url }}/milestones">{{ ms.title }}</a></p>{% else %}<p class="mute small">None.</p>{% endif %} |
|
| 57 | 57 | {% if can_triage && !milestones.is_empty() %}<form class="tiny-form" method="post" action="{{ repo.url }}/issue/{{ i.number }}/milestone"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><select name="milestone"><option value="">none</option>{% for (ms, sel) in milestones %}<option value="{{ ms.id }}"{% if *sel %} selected{% endif %}>{{ ms.title }}</option>{% endfor %}</select> <button type="submit" class="small">SET</button></form>{% endif %} |
|
| 58 | 58 | <h3>Projects</h3> |
|
| 59 | - | {% if projects.is_empty() %}<p class="mute small">None. <a href="/projects/{{ repo.owner }}">{{ repo.owner }}'s projects</a></p>{% else %}<ul class="plain small">{% for (p, col) in projects %}<li><a href="/projects/{{ p.owner }}/{{ p.number }}">{{ p.title }}</a> <span class="mute">· {{ col }}</span></li>{% endfor %}</ul>{% endif %} |
|
| 59 | + | {% if projects.is_empty() %}<p class="mute small">None.</p>{% else %}<ul class="plain small">{% for (pr, col) in projects %}<li><a href="/projects/{{ pr.owner }}/{{ pr.number }}">{{ pr.title }}</a> <span class="mute">· {{ col }}</span>{% if project_choices.is_some() %}<form class="tiny" method="post" action="{{ repo.url }}/issue/{{ i.number }}/project"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><input type="hidden" name="project" value="{{ pr.number }}"><button name="action" value="remove" class="linkish" title="Take it off this project">remove</button></form>{% endif %}</li>{% endfor %}</ul>{% endif %} |
|
| 60 | + | {% if let Some(choices) = project_choices %}{% if choices.is_empty() %}{% if projects.is_empty() %}<p class="small mute"><a href="/projects/{{ repo.owner }}">{{ repo.owner }}'s projects</a></p>{% endif %}{% else %}<form class="side-picker-inline" method="post" action="{{ repo.url }}/issue/{{ i.number }}/project"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><select name="project" aria-label="Project">{% for pr in choices %}<option value="{{ pr.number }}">{{ pr.title }}</option>{% endfor %}</select> <button name="action" value="add" class="small">Add</button></form>{% endif %}{% endif %} |
|
| 60 | 61 | {% if can_triage %} |
|
| 61 | 62 | <h3>Moderation</h3> |
|
| 62 | 63 | <form class="tiny-form" method="post" action="{{ repo.url }}/issue/{{ i.number }}/state"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"> |
|
| … | 9 unchanged lines |
modified templates/proposal.html +75 −40
| … | 2 unchanged lines |
||
| 3 | 3 | {% block title %}Proposal #{{ p.number }} {{ p.title }} · {{ repo.owner }}/{{ repo.name }}{% endblock %} |
|
| 4 | 4 | {% block bodyattrs %} data-repo="{{ repo.url }}"{% endblock %} |
|
| 5 | 5 | {% block tabs %}{% call m::tabs(repo, "proposals") %}{% endblock %} |
|
| 6 | - | {% block mainclass %} class="wide"{% endblock %} |
|
| 6 | + | {% block mainclass %}{% if tab != "conversation" %} class="wide"{% endif %}{% endblock %} |
|
| 7 | 7 | {% block content %} |
|
| 8 | - | <div class="head"><h1>{{ p.title }} <small class="px kind-proposal">proposal #{{ p.number }}</small> |
|
| 8 | + | <div class="head"><h1>{{ p.title }} <small class="px kind-proposal">proposal #{{ p.number }}</small>{% if can_edit %} <a class="go small head-edit" href="#edit" onclick="document.getElementById('edit').open=true">Edit</a>{% endif %} |
|
| 9 | 9 | <small> |
|
| 10 | 10 | <span class="tag{% if p.state == "landed" %} head{% else if p.state == "closed" %} bad{% endif %}">{{ p.state }}</span> |
|
| 11 | 11 | {{ p.author }} wants <span class="lbl lbl-bm">{{ source_label }}</span> into <a class="lbl lbl-bm" href="{{ repo.url }}/tree/{{ p.target }}">{{ p.target }}</a> |
|
| … | 62 unchanged lines |
||
| 74 | 74 | ||
| 75 | 75 | {% else %} |
|
| 76 | 76 | <div class="side-layout conversation"> |
|
| 77 | - | <div> |
|
| 78 | - | {% if !p.body.is_empty() %}<article class="doc">{{ body_html|safe }}</article>{% endif %} |
|
| 79 | - | {% for c in comments %}{% if c.row.anchor_change.is_empty() %} |
|
| 80 | - | <div class="comment"><span class="who">{{ c.row.author }}</span> <time class="mute" title="{{ c.row.created_ms|when_i }}">{{ c.row.created_ms|ago_i }}</time><div class="body">{{ c.body_html|safe }}</div></div> |
|
| 81 | - | {% endif %}{% endfor %} |
|
| 82 | - | {% for r in reviews %} |
|
| 83 | - | <div class="comment review-{{ r.row.verdict }}"><span class="who">{{ r.row.author }}</span> {% if r.row.verdict == "approve" %}{% if r.sealed %}<span class="sig sig-valid">◈</span> approved and sealed{% else %}<span class="sig sig-unsigned">○</span> approved, seal pending{% endif %}{% else if r.row.verdict == "changes" %}<span class="sig sig-invalid">✘</span> requested changes{% else %}commented{% endif %} <time class="mute" title="{{ r.row.created_ms|when_i }}">{{ r.row.created_ms|ago_i }}</time>{% if !r.row.body.is_empty() %}<div class="body">{{ r.body_html|safe }}</div>{% endif %}</div> |
|
| 77 | + | <div class="timeline"> |
|
| 78 | + | <div class="tl-item"> |
|
| 79 | + | <img class="tl-avatar" src="/avatar/{{ p.author }}" alt="" width="36" height="36"> |
|
| 80 | + | <div class="tl-card"> |
|
| 81 | + | <div class="tl-head"><a href="/{{ p.author }}"><strong>{{ p.author }}</strong></a> opened this proposal <time title="{{ p.created_ms|when_i }}">{{ p.created_ms|ago_i }}</time>{% if can_edit %} <a class="tl-edit small" href="#edit">edit</a>{% endif %}</div> |
|
| 82 | + | {% if p.body.is_empty() %}<p class="tl-empty mute">No description.</p>{% else %}<article class="doc tl-doc">{{ body_html|safe }}</article>{% endif %} |
|
| 83 | + | </div> |
|
| 84 | + | </div> |
|
| 85 | + | {% for t in timeline %} |
|
| 86 | + | {% if t.kind == "comment" %}{% let c = comments[t.index] %} |
|
| 87 | + | <div class="tl-item"> |
|
| 88 | + | <img class="tl-avatar" src="/avatar/{{ c.row.author }}" alt="" width="36" height="36"> |
|
| 89 | + | <div class="tl-card"> |
|
| 90 | + | <div class="tl-head"><a href="/{{ c.row.author }}"><strong>{{ c.row.author }}</strong></a> commented <time title="{{ c.row.created_ms|when_i }}">{{ c.row.created_ms|ago_i }}</time></div> |
|
| 91 | + | <div class="tl-text">{{ c.body_html|safe }}</div> |
|
| 92 | + | </div> |
|
| 93 | + | </div> |
|
| 94 | + | {% else if t.kind == "review" %}{% let r = reviews[t.index] %} |
|
| 95 | + | <div class="tl-item review-{{ r.row.verdict }}"> |
|
| 96 | + | <img class="tl-avatar" src="/avatar/{{ r.row.author }}" alt="" width="36" height="36"> |
|
| 97 | + | <div class="tl-card"> |
|
| 98 | + | <div class="tl-head"><a href="/{{ r.row.author }}"><strong>{{ r.row.author }}</strong></a> {% if r.row.verdict == "approve" %}{% if r.sealed %}<span class="sig sig-valid">◈</span> approved and sealed{% else %}<span class="sig sig-unsigned">○</span> approved, seal pending{% endif %}{% else if r.row.verdict == "changes" %}<span class="sig sig-invalid">✘</span> requested changes{% else %}reviewed{% endif %} <time title="{{ r.row.created_ms|when_i }}">{{ r.row.created_ms|ago_i }}</time></div> |
|
| 99 | + | {% if !r.row.body.is_empty() %}<div class="tl-text">{{ r.body_html|safe }}</div>{% endif %} |
|
| 100 | + | </div> |
|
| 101 | + | </div> |
|
| 102 | + | {% else %}{% let e = events[t.index] %} |
|
| 103 | + | <p class="tl-event"><span class="tl-dot"></span><img class="tl-mini" src="/avatar/{{ e.actor }}" alt="" width="18" height="18"> <strong>{{ e.actor }}</strong> {{ e.event }}{% if e.event.ends_with("by change") && !e.detail.is_empty() %}{% let hash = e.detail.split(' ').next().unwrap_or("") %} <a href="{{ repo.url }}/change/{{ hash }}"><code>{{ hash }}</code></a>{{ e.detail[hash.len()..] }}{% else if !e.detail.is_empty() %}: {{ e.detail }}{% endif %} <time class="mute" title="{{ e.at_ms|when_i }}">{{ e.at_ms|ago_i }}</time></p> |
|
| 104 | + | {% endif %} |
|
| 84 | 105 | {% endfor %} |
|
| 85 | - | {% for e in events %}<p class="small mute">{{ e.actor }} {{ e.event }}{% if e.event.ends_with("by change") && !e.detail.is_empty() %}{% let hash = e.detail.split(' ').next().unwrap_or("") %} <a href="{{ repo.url }}/change/{{ hash }}"><code>{{ hash }}</code></a>{{ e.detail[hash.len()..] }}{% else if !e.detail.is_empty() %}: {{ e.detail }}{% endif %} · {{ e.at_ms|ago_i }}</p>{% endfor %} |
|
| 86 | - | {% if shell.user.is_some() %} |
|
| 87 | - | <form class="card wide" method="post" action="{{ repo.url }}/proposal/{{ p.number }}/comment"> |
|
| 88 | - | <input type="hidden" name="_csrf" value="{{ shell.csrf }}"> |
|
| 89 | - | <label for="body">Comment (Markdown)</label><textarea id="body" name="body" class="md" rows="4" required></textarea> |
|
| 90 | - | <button type="submit">COMMENT</button> |
|
| 91 | - | </form> |
|
| 106 | + | {% if let Some(me) = shell.user %} |
|
| 107 | + | <div class="tl-item"> |
|
| 108 | + | <img class="tl-avatar" src="/avatar/{{ me }}" alt="" width="36" height="36"> |
|
| 109 | + | <div class="tl-card"> |
|
| 110 | + | <form method="post" action="{{ repo.url }}/proposal/{{ p.number }}/comment"> |
|
| 111 | + | <input type="hidden" name="_csrf" value="{{ shell.csrf }}"> |
|
| 112 | + | <label for="body">Comment (Markdown)</label><textarea id="body" name="body" class="md" rows="4" required></textarea> |
|
| 113 | + | <div class="tl-actions"><button type="submit" class="primary-btn">Comment</button></div> |
|
| 114 | + | </form> |
|
| 115 | + | {% if can_edit && (p.state == "draft" || p.is_open() || p.state == "closed") %} |
|
| 116 | + | <form class="tl-actions" method="post" action="{{ repo.url }}/proposal/{{ p.number }}/state"> |
|
| 117 | + | <input type="hidden" name="_csrf" value="{{ shell.csrf }}"> |
|
| 118 | + | {% if p.state == "draft" %}<button type="submit" name="action" value="ready">Ready for review</button>{% endif %} |
|
| 119 | + | {% if p.state == "open" %}<button type="submit" name="action" value="draft">Back to draft</button>{% endif %} |
|
| 120 | + | {% if p.is_open() %}<button type="submit" name="action" value="close" class="danger-btn">Close proposal</button>{% endif %} |
|
| 121 | + | {% if p.state == "closed" %}<button type="submit" name="action" value="reopen">Reopen</button>{% endif %} |
|
| 122 | + | </form> |
|
| 123 | + | {% endif %} |
|
| 124 | + | </div> |
|
| 125 | + | </div> |
|
| 92 | 126 | {% if p.is_open() %} |
|
| 93 | - | <form class="card wide" method="post" action="{{ repo.url }}/proposal/{{ p.number }}/review"> |
|
| 127 | + | <form class="card tl-review" method="post" action="{{ repo.url }}/proposal/{{ p.number }}/review"> |
|
| 94 | 128 | <input type="hidden" name="_csrf" value="{{ shell.csrf }}"> |
|
| 95 | 129 | <h2>Review</h2> |
|
| 96 | 130 | <label for="rbody">Summary (optional)</label><textarea id="rbody" name="body" rows="3"></textarea> |
|
| … | 7 unchanged lines |
||
| 104 | 138 | </form> |
|
| 105 | 139 | {% endif %} |
|
| 106 | 140 | {% endif %} |
|
| 141 | + | {% if can_edit %} |
|
| 142 | + | <details class="panel tl-editbox" id="edit"> |
|
| 143 | + | <summary>Edit title, target and description</summary> |
|
| 144 | + | <form method="post" action="{{ repo.url }}/proposal/{{ p.number }}/edit"> |
|
| 145 | + | <input type="hidden" name="_csrf" value="{{ shell.csrf }}"> |
|
| 146 | + | <label for="etitle">Title</label><input type="text" id="etitle" name="title" value="{{ p.title }}"> |
|
| 147 | + | <label for="etarget">Target bookmark</label><input type="text" id="etarget" name="target" value="{{ p.target }}"> |
|
| 148 | + | <label for="ebody">Description (Markdown)</label><textarea id="ebody" name="body" class="md" rows="8">{{ p.body }}</textarea> |
|
| 149 | + | <div class="tl-actions"><button type="submit" class="primary-btn">Save</button></div> |
|
| 150 | + | </form> |
|
| 151 | + | </details> |
|
| 152 | + | {% endif %} |
|
| 107 | 153 | </div> |
|
| 108 | 154 | <aside class="side"> |
|
| 109 | 155 | <h3>Landing</h3> |
|
| … | 7 unchanged lines |
||
| 117 | 163 | {% endif %} |
|
| 118 | 164 | {% endif %} |
|
| 119 | 165 | {% if !closes.is_empty() %}<h3>Closes</h3><ul class="plain">{% for c in closes %}<li><a href="{{ c.url }}" class="kind-issue">issue #{{ c.number }}</a> {{ c.title }} <span class="small mute">{{ c.state }}</span></li>{% endfor %}</ul>{% endif %} |
|
| 120 | - | <h3>Labels</h3> |
|
| 121 | - | {% if labels.is_empty() %}<p class="mute small">None.</p>{% else %}<p>{% for l in labels %}<span class="label" style="--c: {{ l.colour }}">{{ l.name }}</span> {% endfor %}</p>{% endif %} |
|
| 122 | - | {% if repo.can(crate::auth::Access::Write) && !all_labels.is_empty() %}<form class="tiny-form" method="post" action="{{ repo.url }}/proposal/{{ p.number }}/labels"><input type="hidden" name="_csrf" value="{{ shell.csrf }}">{% for l in all_labels %}<label class="small"><input type="checkbox" name="label" value="{{ l.id }}"{% if labels.contains(l) %} checked{% endif %}> {{ l.name }}</label> {% endfor %}<button type="submit" class="small">SET</button></form>{% endif %} |
|
| 166 | + | <h3>Labels{% if can_triage %} <a class="side-edit" href="{{ repo.url }}/labels" title="Manage this repository's labels">manage</a>{% endif %}</h3> |
|
| 167 | + | {% if labels.is_empty() %}<p class="mute small">None.</p>{% else %}<p>{% for l in labels %}<span class="label" style="--c: {{ l.colour }}" title="{{ l.description }}">{{ l.name }}</span> {% endfor %}</p>{% endif %} |
|
| 168 | + | {% if can_triage %} |
|
| 169 | + | {% if all_labels.is_empty() %}<p class="small mute">This repository has no labels yet: <a href="{{ repo.url }}/labels">create some</a>.</p> |
|
| 170 | + | {% else %}<details class="side-picker"><summary>Edit labels</summary> |
|
| 171 | + | <form method="post" action="{{ repo.url }}/proposal/{{ p.number }}/labels"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"> |
|
| 172 | + | {% for l in all_labels %}<label class="side-check"><input type="checkbox" name="label" value="{{ l.id }}"{% if labels.contains(l) %} checked{% endif %}> <span class="label" style="--c: {{ l.colour }}">{{ l.name }}</span></label>{% endfor %} |
|
| 173 | + | <button type="submit" class="small">Apply</button> |
|
| 174 | + | </form></details>{% endif %} |
|
| 175 | + | {% endif %} |
|
| 123 | 176 | <h3>Projects</h3> |
|
| 124 | - | {% if projects.is_empty() %}<p class="mute small">None.</p>{% else %}<ul class="plain small">{% for (pr, col) in projects %}<li><a href="/projects/{{ pr.owner }}/{{ pr.number }}">{{ pr.title }}</a> <span class="mute">· {{ col }}</span></li>{% endfor %}</ul>{% endif %} |
|
| 177 | + | {% if projects.is_empty() %}<p class="mute small">None.</p>{% else %}<ul class="plain small">{% for (pr, col) in projects %}<li><a href="/projects/{{ pr.owner }}/{{ pr.number }}">{{ pr.title }}</a> <span class="mute">· {{ col }}</span>{% if project_choices.is_some() %}<form class="tiny" method="post" action="{{ repo.url }}/proposal/{{ p.number }}/project"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><input type="hidden" name="project" value="{{ pr.number }}"><button name="action" value="remove" class="linkish" title="Take it off this project">remove</button></form>{% endif %}</li>{% endfor %}</ul>{% endif %} |
|
| 178 | + | {% if let Some(choices) = project_choices %}{% if choices.is_empty() %}{% if projects.is_empty() %}<p class="small mute"><a href="/projects/{{ repo.owner }}">{{ repo.owner }}'s projects</a></p>{% endif %}{% else %}<form class="side-picker-inline" method="post" action="{{ repo.url }}/proposal/{{ p.number }}/project"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><select name="project" aria-label="Project">{% for pr in choices %}<option value="{{ pr.number }}">{{ pr.title }}</option>{% endfor %}</select> <button name="action" value="add" class="small">Add</button></form>{% endif %}{% endif %} |
|
| 125 | 179 | <h3>Checks</h3> |
|
| 126 | 180 | <div id="checks"> |
|
| 127 | 181 | {% if checks.is_empty() %}<p class="mute small">None reported on the projected state{% if !required_checks.is_empty() %}; required: {{ required_checks.join(", ") }}{% endif %}.</p>{% else %} |
|
| … | 5 unchanged lines |
||
| 133 | 187 | <ul class="plain">{% for a in approvals %}<li>{% if a.sealed %}<span class="sig sig-valid">◈</span>{% else %}<span class="sig sig-unsigned">○</span>{% endif %} <a href="/{{ a.account }}">{{ a.account }}</a>{% if !a.sealed %} <span class="mute small">seal pending</span>{% endif %}</li>{% endfor %}</ul>{% endif %} |
|
| 134 | 188 | {% if !owners.is_empty() %}<h3>Code owners</h3><ul class="plain">{% for o in owners %}<li>{{ o }}</li>{% endfor %}</ul>{% endif %} |
|
| 135 | 189 | {% if !depends_on.is_empty() %}<h3>Depends on</h3><ul class="plain">{% for n in depends_on %}<li><a href="{{ repo.url }}/proposal/{{ n }}">#{{ n }}</a></li>{% endfor %}</ul>{% endif %} |
|
| 136 | - | {% if can_edit %} |
|
| 137 | - | <h3>Edit</h3> |
|
| 138 | - | <form class="card" method="post" action="{{ repo.url }}/proposal/{{ p.number }}/edit"> |
|
| 139 | - | <input type="hidden" name="_csrf" value="{{ shell.csrf }}"> |
|
| 140 | - | <label for="etitle">Title</label><input type="text" id="etitle" name="title" value="{{ p.title }}"> |
|
| 141 | - | <label for="etarget">Target</label><input type="text" id="etarget" name="target" value="{{ p.target }}"> |
|
| 142 | - | <label for="ebody">Description</label><textarea id="ebody" name="body" rows="4">{{ p.body }}</textarea> |
|
| 143 | - | <button type="submit">SAVE</button> |
|
| 144 | - | </form> |
|
| 145 | - | <form class="card" method="post" action="{{ repo.url }}/proposal/{{ p.number }}/state"> |
|
| 146 | - | <input type="hidden" name="_csrf" value="{{ shell.csrf }}"> |
|
| 147 | - | <div class="choice"> |
|
| 148 | - | {% if p.state == "draft" %}<button type="submit" name="action" value="ready">READY FOR REVIEW</button>{% endif %} |
|
| 149 | - | {% if p.state == "open" %}<button type="submit" name="action" value="draft">BACK TO DRAFT</button>{% endif %} |
|
| 150 | - | {% if p.is_open() %}<button type="submit" name="action" value="close">CLOSE</button>{% endif %} |
|
| 151 | - | {% if p.state == "closed" %}<button type="submit" name="action" value="reopen">REOPEN</button>{% endif %} |
|
| 152 | - | </div> |
|
| 153 | - | </form> |
|
| 154 | - | {% endif %} |
|
| 155 | 190 | </aside> |
|
| 156 | 191 | </div> |
|
| 157 | 192 | {% endif %} |
|
| … | 1 unchanged lines |
modified templates/style.css +36 −0
| … | 600 unchanged lines |
||
| 601 | 601 | .st-closed .chain-state { color: var(--mute); } |
|
| 602 | 602 | .chain-arrow { color: var(--acc); font-weight: 700; } |
|
| 603 | 603 | .chain-and { color: var(--mute); } |
|
| 604 | + | ||
| 605 | + | /* A proposal's conversation as a timeline (hub spec §7): avatars on a rail, |
|
| 606 | + | each entry a card with its author and time, events as quiet lines. */ |
|
| 607 | + | .timeline { position: relative; min-width: 0; } |
|
| 608 | + | .timeline::before { content: ""; position: absolute; left: 17px; top: 12px; bottom: 12px; border-left: 2px solid var(--line); z-index: 0; } |
|
| 609 | + | .tl-item { position: relative; display: grid; grid-template-columns: 36px minmax(0, 1fr); gap: 14px; margin: 0 0 18px; z-index: 1; } |
|
| 610 | + | .tl-avatar { width: 36px; height: 36px; border-radius: 50%; border: 2px solid var(--bg); background: var(--panel); } |
|
| 611 | + | .tl-card { min-width: 0; background: var(--panel); border: 1px solid var(--line); } |
|
| 612 | + | .tl-head { padding: 8px 14px; border-bottom: 1px solid var(--line); background: var(--soft); font-size: 13px; color: var(--mute); } |
|
| 613 | + | .tl-head strong { color: var(--ink); } |
|
| 614 | + | .tl-head .tl-edit { float: right; } |
|
| 615 | + | .tl-card article.doc.tl-doc { max-width: none; margin: 0; border: 0; } |
|
| 616 | + | .tl-text { padding: 10px 14px; overflow-wrap: anywhere; } |
|
| 617 | + | .tl-empty { padding: 10px 14px; margin: 0; } |
|
| 618 | + | .tl-card form { padding: 10px 14px; margin: 0; } |
|
| 619 | + | .tl-card textarea { width: 100%; box-sizing: border-box; } |
|
| 620 | + | .tl-actions { display: flex; flex-wrap: wrap; gap: 8px; justify-content: flex-end; padding-top: 0; } |
|
| 621 | + | form.tl-actions { padding: 0 14px 12px; } |
|
| 622 | + | .tl-item.review-approve .tl-card { border-color: var(--ok); } |
|
| 623 | + | .tl-item.review-changes .tl-card { border-color: var(--bad); } |
|
| 624 | + | .tl-event { position: relative; z-index: 1; display: flex; align-items: center; gap: 6px; margin: -6px 0 16px 10px; font-size: 13px; color: var(--mute); } |
|
| 625 | + | .tl-event strong { color: var(--ink); font-weight: 600; } |
|
| 626 | + | .tl-dot { width: 14px; height: 14px; border-radius: 50%; background: var(--soft); border: 2px solid var(--line); flex: none; } |
|
| 627 | + | .tl-mini { width: 18px; height: 18px; border-radius: 50%; } |
|
| 628 | + | .timeline form.card.tl-review { max-width: none; margin-left: 50px; } |
|
| 629 | + | .tl-editbox { margin-left: 50px; } |
|
| 630 | + | .tl-editbox summary { cursor: pointer; font-weight: 600; } |
|
| 631 | + | .tl-editbox input[type=text], .tl-editbox textarea { width: 100%; box-sizing: border-box; } |
|
| 632 | + | .head-edit { margin-left: 8px; vertical-align: middle; } |
|
| 633 | + | aside.side h3 .side-edit { float: right; font-size: 11px; font-weight: 400; text-transform: none; letter-spacing: 0; } |
|
| 634 | + | .side-picker summary { cursor: pointer; font-size: 12px; color: var(--acc); } |
|
| 635 | + | .side-picker form { display: flex; flex-direction: column; gap: 4px; margin-top: 6px; } |
|
| 636 | + | .side-check { display: flex; align-items: center; gap: 6px; font-size: 13px; } |
|
| 637 | + | .side-picker-inline { display: flex; gap: 6px; align-items: center; margin-top: 4px; } |
|
| 638 | + | .side-picker-inline select { flex: 1; min-width: 0; } |
|
| 639 | + | @media (max-width: 760px) { .timeline::before { display: none; } .tl-item { grid-template-columns: 1fr; } .tl-avatar { display: none; } .timeline form.card.tl-review, .tl-editbox { margin-left: 0; } } |