2542369203329244998d44d82a21038b435a547b / frogue / src / views / patches.rs
Raw
use actix_web::HttpResponse;
use maud::{Markup, PreEscaped, Render, html};
use tracing::instrument;
use super::{
Page,
commit::{diff_stats_summary, file_diffs},
components::{
AuthorName, Complaint, FilterLink, RepositoryChrome, RepositoryTab, TextArea, TextField,
},
repositories::CommitSummary,
};
/// A repository's patches, as the list page shows them.
pub struct PatchesDetail {
pub slug: String,
pub visibility: &'static str,
/// The branch the repository opens on, for the browse tab.
pub default_branch: String,
/// The patches the filter left, newest first.
pub patches: Vec<PatchRow>,
/// Whether the visitor is signed in, and so has somebody to open a
/// patch as. A stranger is not offered a way they cannot use.
pub can_open: bool,
/// What filter is currently applied (draft, open, closed, merged, or all)
pub state_filter: String,
}
/// One patch as the list shows it.
pub struct PatchRow {
pub number: i64,
/// The summary as it was written: plain text here, because a list is
/// scanned rather than read, and a patch's own page is where its
/// markdown belongs.
pub summary: String,
/// Who opened it, by name.
pub creator: crate::comments::CommentAuthor,
/// The status of the patch (draft, open, closed, merged).
pub status: String,
}
pub(super) struct PatchesBody<'a> {
owner: &'a str,
detail: &'a PatchesDetail,
viewer: Option<&'a str>,
}
impl Render for PatchesBody<'_> {
fn render(&self) -> Markup {
let detail = self.detail;
let base = format!("/~{}/{}", self.owner, detail.slug);
html! {
(RepositoryChrome {
owner: self.owner,
slug: &detail.slug,
visibility: detail.visibility,
default_branch: &detail.default_branch,
current_tab: RepositoryTab::Patches,
is_owner: self.viewer == Some(self.owner),
})
// State filter tabs
nav class="patch-filters" {
(FilterLink {
href: "",
label: "All",
base_class: "patch-label",
selected: detail.state_filter == "all",
})
(FilterLink {
href: "?state=draft",
label: "Draft",
base_class: "patch-label",
selected: detail.state_filter == "draft",
})
(FilterLink {
href: "?state=open",
label: "Open",
base_class: "patch-label",
selected: detail.state_filter == "open",
})
(FilterLink {
href: "?state=closed",
label: "Closed",
base_class: "patch-label",
selected: detail.state_filter == "closed",
})
(FilterLink {
href: "?state=merged",
label: "Merged",
base_class: "patch-label",
selected: detail.state_filter == "merged",
})
}
@if detail.can_open {
p class="patch-tools" {
"Patches are created by pushing to " code { "refs/patches" } " over SSH."
}
}
@if detail.patches.is_empty() {
p class="empty" { "Nothing here yet." }
} @else {
ul class="patches" {
@for patch in &detail.patches {
li {
a class="patch-summary"
href=(format!("{base}/patches/{}", patch.number)) {
"#" (patch.number) " " (patch.summary)
}
span class="patch-status" { "(" (patch.status) ")" }
span class="patch-creator" {
" by "
(AuthorName(&patch.creator))
}
}
}
}
}
}
}
}
/// A patch's detail page, as the viewer's own page shows it.
pub struct PatchDetail<'a> {
pub slug: String,
pub visibility: &'static str,
pub default_branch: String,
pub number: i64,
pub summary: String,
pub description_html: String,
pub creator: crate::comments::CommentAuthor,
pub created_at: String,
pub status: String,
pub source_ref: String,
pub target_branch: String,
pub merge_policy: String,
/// Whether the viewer may change this patch.
pub may_change: bool,
/// Whether the viewer is the repository owner.
pub is_owner: bool,
/// The commits in this patch, newest first.
pub commits: Vec<CommitSummary>,
/// The files changed in this patch.
pub files: Vec<crate::repositories::git::DiffFile>,
/// The comments on this patch, in chronological order.
pub comments: Vec<super::comments::CommentView<'a>>,
/// Whether the visitor may comment on this patch.
pub may_comment: bool,
/// The complaint, if the last comment attempt was refused.
pub comment_error: Option<String>,
/// The draft, if the last comment attempt was refused.
pub comment_draft: Option<String>,
/// The jj change-id, if this patch carries one.
pub change_id: Option<String>,
}
pub(super) struct PatchBody<'a> {
owner: &'a str,
detail: &'a PatchDetail<'a>,
viewer: Option<&'a str>,
}
impl Render for PatchBody<'_> {
fn render(&self) -> Markup {
let detail = self.detail;
let base = format!("/~{}/{}", self.owner, detail.slug);
html! {
(RepositoryChrome {
owner: self.owner,
slug: &detail.slug,
visibility: detail.visibility,
default_branch: &detail.default_branch,
current_tab: RepositoryTab::Patches,
is_owner: self.viewer == Some(self.owner),
})
article class="patch-detail" {
header {
div class="patch-header-top" {
h2 { "#" (detail.number) " " (detail.summary) }
@if detail.may_change {
a href=(format!("{base}/patches/{}/edit", detail.number)) class="patch-edit-link" { "Edit" }
}
}
dl class="patch-meta" {
dt { "Status" }
dd { (detail.status) }
dt { "Author" }
dd {
(AuthorName(&detail.creator))
}
dt { "Opened" }
dd { (detail.created_at) }
dt { "Source" }
dd { code { (detail.source_ref) } }
dt { "Target" }
dd { code { (detail.target_branch) } }
dt { "Merge policy" }
dd { (detail.merge_policy) }
@if let Some(change_id) = &detail.change_id {
dt { "Change ID" }
dd { code class="change-id" { (change_id) } }
}
}
}
section class="patch-description" {
(PreEscaped(detail.description_html.clone()))
}
@if !detail.commits.is_empty() {
section class="patch-commits" {
h3 { "Commits (" (detail.commits.len()) ")" }
ul class="commits" {
@for commit in &detail.commits {
li {
(super::commits::CommitLine {
owner: self.owner,
slug: &detail.slug,
commit,
})
}
}
}
}
}
@if !detail.files.is_empty() {
section class="patch-diff" {
h3 { "Changes" }
div class="commit-stats" {
(diff_stats_summary(&detail.files))
}
div class="commit-diff" {
(file_diffs(&detail.files))
}
}
}
@if detail.may_change && detail.is_owner && (detail.status == "draft" || detail.status == "open") {
section class="patch-actions" {
form method="post" action=(format!("{base}/patches/{}/merge", detail.number)) class="inline-form" {
button type="submit" class="merge-button" { "Merge" }
}
}
}
(super::comments::CommentListView {
comments: &detail.comments,
may_comment: detail.may_comment,
base_comments_url: &format!("{base}/patches/{}/comments", detail.number),
comment_draft: detail.comment_draft.as_deref(),
comment_error: detail.comment_error.as_deref()
})
}
}
}
}
/// What a patch edit form is asked to show.
pub struct PatchEditForm {
pub action: String,
pub slug: String,
pub visibility: &'static str,
pub default_branch: String,
pub number: i64,
pub summary: Option<String>,
pub description: Option<String>,
pub target_branch: String,
pub branches: Vec<String>,
pub status: String,
pub merge_policy: String,
pub error: Option<String>,
pub submit: &'static str,
/// Whether the viewer is the repository owner (can change merge policy).
pub is_owner: bool,
}
pub(super) struct PatchEditBody<'a> {
owner: &'a str,
form: &'a PatchEditForm,
viewer: Option<&'a str>,
}
impl Render for PatchEditBody<'_> {
fn render(&self) -> Markup {
let form = self.form;
html! {
(RepositoryChrome {
owner: self.owner,
slug: &form.slug,
visibility: form.visibility,
default_branch: &form.default_branch,
current_tab: RepositoryTab::Patches,
is_owner: self.viewer == Some(self.owner),
})
@if let Some(error) = &form.error {
(Complaint(Some(error)))
}
form method="post" action=(form.action) class="patch-edit" {
fieldset {
legend { "Summary" }
(TextField {
name: "summary",
label: None,
input_type: "text",
value: form.summary.as_deref(),
required: true,
maxlength: 255,
})
}
fieldset {
legend { "Description" }
(TextArea {
name: "description",
label: None,
rows: 10,
value: form.description.as_deref(),
required: false,
})
}
fieldset {
legend { "Target branch" }
select name="target_branch" required {
@for branch in &form.branches {
option value=(branch) selected[branch == &form.target_branch] { (branch) }
}
}
}
fieldset {
legend { "Status" }
select name="status" {
option value="draft" selected[form.status == "draft"] { "Draft" }
option value="open" selected[form.status == "open"] { "Open" }
option value="closed" selected[form.status == "closed"] { "Closed" }
}
}
@if form.is_owner {
fieldset {
legend { "Merge policy" }
(super::form::MergePolicySelect {
name: "merge_policy",
current: &form.merge_policy,
})
}
}
button type="submit" { (form.submit) }
}
}
}
}
/// A repository's patches, as the list page shows them.
pub fn patches(viewer: Option<&str>, owner: &str, detail: &PatchesDetail) -> HttpResponse {
Page {
title: format!("{owner}/{}/patches", detail.slug),
viewer: viewer.map(str::to_string),
extra_head: None,
body: PatchesBody {
owner,
detail,
viewer,
},
}
.into_response()
}
/// A patch's own page.
#[instrument(skip(detail), fields(patch_slug=detail.slug))]
pub fn patch(viewer: Option<&str>, owner: &str, detail: &PatchDetail) -> HttpResponse {
Page {
title: format!("{owner}/{}/patches/{}", detail.slug, detail.number),
viewer: viewer.map(str::to_string),
extra_head: None,
body: PatchBody {
owner,
detail,
viewer,
},
}
.into_response()
}
/// A patch edit form.
pub fn patch_form(viewer: Option<&str>, owner: &str, form: &PatchEditForm) -> HttpResponse {
Page {
title: format!("{owner}/{}/patches/{}/edit", form.slug, form.number),
viewer: viewer.map(str::to_string),
extra_head: None,
body: PatchEditBody {
owner,
form,
viewer,
},
}
.into_response()
}