Jump to…
snowfix: 500 error because of database is patchedyuzpxzopsouq1mo
Matt W1//! Change detail: overview, files, revisions, conflicts, and the stack graph
Matt W2//! (M3, M4).
Matt W3//!
Matt W4//! Everything here renders standalone. The comment forms are ordinary POSTs to
Matt W5//! ordinary URLs, so a reviewer with JavaScript disabled can read a diff and
Matt W6//! leave an inline comment — which spec §7 makes a hard requirement, and which
Matt W7//! is the single easiest thing to lose when a diff view grows interactive.
Matt W8//!
Matt W9//! htmx enhances two things and nothing else: posting a comment swaps just its
Matt W10//! thread, and expanding collapsed context fetches only the extra lines.
Matt W11
Matt W12use chrono::{DateTime, Utc};
Matt W13use maud::{html, Markup, PreEscaped};
Matt W14use uuid::Uuid;
Matt W15
Matt W16use df_store::{ConflictedFile, Diff, DiffLine, FileDiff};
Matt W17
Matt W18use crate::repo_ctx::RepoContext;
Matt W19use crate::views::change::{change_chip, state_badge};
Matt W20use crate::views::diff::{
Matt W21 self, line_class, marker, path_anchor, spans, stat_summary, DiffView, LineHooks,
Matt W22};
Matt W23
Matt W24// ─── shared shape ────────────────────────────────────────────────────────────
Matt W25
Matt W26/// Everything the change pages need about the change itself.
Matt W27pub struct ChangeHead<'a> {
Matt W28 pub number: i64,
Matt W29 pub change_id: &'a str,
Matt W30 pub synthetic: bool,
Matt W31 pub title: &'a str,
Matt W32 pub state: &'a str,
Matt W33 pub conflicted: bool,
Matt W34 pub target_bookmark: &'a str,
Matt W35 pub author: Option<&'a str>,
Matt W36 /// The name the commit itself carries, used when no account matched.
Matt W37 pub author_name: Option<&'a str>,
Matt W38 pub revision_count: usize,
Matt W39 /// The head revision's commit id, abbreviated.
Matt W40 pub head_commit: Option<&'a str>,
Matt W41 pub created_at: DateTime<Utc>,
Matt W42 pub updated_at: DateTime<Utc>,
Matt W43 pub file_count: Option<usize>,
Matt W44 pub comment_count: i64,
Matt W45 /// Whether the viewer may edit the change (author or maintainer).
Matt W46 pub can_manage: bool,
Matt W47 pub can_comment: bool,
Matt W48 pub csrf: &'a str,
Matt W49}
Matt W50
Matt W51/// One node of the stack rail in the aside.
Matt W52pub struct StackNodeMini {
Matt W53 pub change_id: String,
Matt W54 pub number: i64,
Matt W55 pub state: String,
Matt W56 pub conflicted: bool,
Matt W57 pub depth: usize,
Matt W58 pub is_current: bool,
Matt W59}
Matt W60
Matt W61/// The right-hand column, identical on every change tab.
Matt W62pub struct ChangeAside {
Matt W63 pub reviewers: Vec<crate::views::change::Reviewer>,
Matt W64 pub stack: Vec<StackNodeMini>,
Matt W65}
Matt W66
Matt W67/// The change header: the id at display size, the state, and the tabs.
Matt W68///
Matt W69/// The change id is the largest thing on the page — larger than the title.
Matt W70/// That is deliberate and it is the argument the product is making: the title
Matt W71/// is prose somebody typed and can retype, the id is what every review,
Matt W72/// approval and permalink is attached to, and it does not change when the
Matt W73/// commit underneath it does.
Matt W74pub fn header(ctx: &RepoContext, c: &ChangeHead<'_>, tab: &str) -> Markup {
Matt W75 let base = format!("{}/changes/{}", ctx.base(), c.number);
Matt W76
Matt W77 html! {
Matt W78 div .change-head {
Matt W79 div .change-head-top {
Matt W80 span .change-head-rail aria-hidden="true" {}
Matt W81 div .change-head-id {
Matt W82 div .change-head-line {
Matt W83 @if c.synthetic {
Matt W84 span .change-id-display.is-synthetic
Matt W85 title="Authored with plain git — identity derived from the patch" {
Matt W86 (&c.change_id[..12.min(c.change_id.len())])
Matt W87 }
Matt W88 } @else {
Matt W89 span .change-id-display title=(format!("jj change id: {}", c.change_id)) {
Matt W90 (crate::views::repo::cid_parts(c.change_id))
Matt W91 }
Matt W92 }
Matt W93 (state_badge(c.state, c.conflicted))
Matt W94 span .faint.mono { "#" (c.number) }
Matt W95 }
Matt W96 h1 .change-title { (c.title) }
Matt W97 div .change-byline {
Matt W98 @if c.author.is_some() || c.author_name.is_some() {
Matt W99 span { (crate::views::person(c.author, c.author_name)) " →" }
Matt W100 }
Matt W101 span .chip { (c.target_bookmark) }
Matt W102 span .sep aria-hidden="true" { "·" }
Matt W103 span { (revision_span(c)) }
Matt W104 @if let Some(h) = c.head_commit {
Matt W105 span .sep aria-hidden="true" { "·" }
Matt W106 span { "commit " span .mono.faint { (h) } }
Matt W107 }
Matt W108 }
Matt W109 }
Matt W110 }
Matt W111
Matt W112 nav .subtabs.ruled aria-label="Change sections" {
Matt W113 a href=(base.clone()) .active[tab == "overview"]
Matt W114 aria-current=[(tab == "overview").then_some("page")] {
Matt W115 "Overview"
Matt W116 @if c.comment_count > 0 {
Matt W117 span .tab-count { (c.comment_count) }
Matt W118 }
Matt W119 }
Matt W120 a href=(format!("{base}/files")) .active[tab == "files"]
Matt W121 aria-current=[(tab == "files").then_some("page")] {
Matt W122 "Files"
Matt W123 @if let Some(n) = c.file_count {
Matt W124 span .tab-count { (n) }
Matt W125 }
Matt W126 }
Matt W127 a href=(format!("{base}/revisions")) .active[tab == "revisions"]
Matt W128 aria-current=[(tab == "revisions").then_some("page")] {
Matt W129 "Revisions"
Matt W130 @if c.revision_count > 1 {
Matt W131 span .tab-count { (c.revision_count) }
Matt W132 }
Matt W133 }
Matt W134 a href=(format!("{base}/checks")) .active[tab == "checks"]
Matt W135 aria-current=[(tab == "checks").then_some("page")] { "Checks" }
Matt W136 @if c.conflicted {
Matt W137 a href=(format!("{base}/conflicts")) .active[tab == "conflicts"]
Matt W138 aria-current=[(tab == "conflicts").then_some("page")] { "Conflicts" }
Matt W139 }
Matt W140 a href=(format!("{}/stacks/{}", ctx.base(), c.change_id)) { "Stack" }
Matt W141 }
Matt W142 }
Matt W143 }
Matt W144}
Matt W145
Matt W146/// "4 revisions over 3 days" — the sentence stable identity makes possible.
Matt W147///
Matt W148/// A single-revision change gets "1 revision" and no span: "over 0 days" is
Matt W149/// noise, and the interesting number is the one that says this change has been
Matt W150/// rewritten and kept its name.
Matt W151fn revision_span(c: &ChangeHead<'_>) -> String {
Matt W152 let n = c.revision_count;
Matt W153 let unit = if n == 1 { "revision" } else { "revisions" };
Matt W154 let days = (c.updated_at - c.created_at).num_days();
Matt W155
Matt W156 match (n, days) {
Matt W157 (1, _) => format!("1 {unit}"),
Matt W158 (_, 0) => format!("{n} {unit}"),
Matt W159 (_, 1) => format!("{n} {unit} over a day"),
Matt W160 (_, d) => format!("{n} {unit} over {d} days"),
Matt W161 }
Matt W162}
Matt W163
Matt W164/// Wrap a change tab's body in the two-column shell with the aside.
Matt W165pub fn tab_body(ctx: &RepoContext, aside: &ChangeAside, body: Markup) -> Markup {
Matt W166 html! {
Matt W167 div .columns.columns-repo {
Matt W168 div .columns-main { (body) }
Matt W169 aside .columns-aside.is-sticky {
Matt W170 @if !aside.reviewers.is_empty() {
Matt W171 div .aside-block {
Matt W172 div .label-condensed { "Reviewers" }
Matt W173 @for rv in &aside.reviewers {
Matt W174 (reviewer_line(rv))
Matt W175 }
Matt W176 }
Matt W177 }
Matt W178 @if aside.stack.len() > 1 {
Matt W179 div .aside-block {
Matt W180 div .label-condensed { "Stack" }
Matt W181 @for n in &aside.stack {
Matt W182 (stack_rail_row(ctx, n))
Matt W183 }
Matt W184 }
Matt W185 }
Matt W186 }
Matt W187 }
Matt W188 }
Matt W189}
Matt W190
Matt W191fn reviewer_line(rv: &crate::views::change::Reviewer) -> Markup {
Matt W192 let (glyph, colour, meta, meta_colour) = match (rv.verdict.as_str(), rv.at_head) {
Matt W193 ("approved", true) => ("✓", "var(--open)", "approved", "var(--text-faint)"),
Matt W194 // A stale approval is the thing stable identity makes visible. It gets
Matt W195 // the conflict colour because it is a state the author has to act on,
Matt W196 // not a verdict they can bank.
Matt W197 ("approved", false) => ("✓", "var(--open)", "stale", "var(--conflict)"),
Matt W198 ("rejected", _) => ("×", "var(--danger)", "changes requested", "var(--danger)"),
Matt W199 _ => ("○", "var(--text-faint)", "commented", "var(--text-faint)"),
Matt W200 };
Matt W201
Matt W202 html! {
Matt W203 div .reviewer-line {
Matt W204 span .reviewer-glyph aria-hidden="true" style=(format!("color:{colour}")) { (glyph) }
Matt W205 (crate::views::user_link(&rv.handle))
Matt W206 span .spacer {}
Matt W207 span .reviewer-meta style=(format!("color:{meta_colour}")) { (meta) }
Matt W208 }
Matt W209 }
Matt W210}
Matt W211
Matt W212fn stack_rail_row(ctx: &RepoContext, n: &StackNodeMini) -> Markup {
Matt W213 let (glyph, colour) = match (n.conflicted, n.state.as_str()) {
Matt W214 (true, _) => ("◆", "var(--conflict)"),
Matt W215 (_, "merged") => ("⤳", "var(--merged)"),
Matt W216 (_, "abandoned") => ("×", "var(--abandoned)"),
Matt W217 _ => ("○", "var(--open)"),
Matt W218 };
Matt W219
Matt W220 html! {
Matt W221 a .mini-row .is-current[n.is_current]
Matt W222 href=(format!("{}/changes/{}", ctx.base(), n.number)) {
Matt W223 span .cl-indent style=(format!("width:{}px", n.depth * 6)) {}
Matt W224 span .mini-rail aria-hidden="true" {}
Matt W225 span .mini-glyph aria-hidden="true" style=(format!("color:{colour}")) { (glyph) }
Matt W226 (change_chip(&n.change_id, false))
Matt W227 }
Matt W228 }
Matt W229}
Matt W230
Matt W231// ─── overview ────────────────────────────────────────────────────────────────
Matt W232
Matt W233pub struct CommentRow {
Matt W234 pub id: Uuid,
Matt W235 pub author: String,
Matt W236 pub body_html: String,
Matt W237 pub created_at: DateTime<Utc>,
Matt W238 pub edited: bool,
Matt W239 pub anchor_path: Option<String>,
Matt W240 pub anchor_line: Option<i32>,
Matt W241 /// `old` or `new`. Stored and loaded because a comment on a deleted line is
Matt W242 /// a different thing from one on an added line, and losing that on the way
Matt W243 /// in would be unrecoverable — the diff view currently anchors on the new
Matt W244 /// side only, so nothing reads it yet.
Matt W245 #[allow(dead_code)]
Matt W246 pub anchor_side: Option<String>,
Matt W247 pub anchor_state: String,
Matt W248 pub anchor_context: Option<String>,
Matt W249 pub resolved: bool,
Matt W250}
Matt W251
Matt W252pub struct ReviewRow {
Matt W253 pub reviewer: String,
Matt W254 pub verdict: String,
Matt W255 pub body_html: String,
Matt W256 pub created_at: DateTime<Utc>,
Matt W257 /// The revision the review was left against, abbreviated.
Matt W258 pub rev: String,
Matt W259 /// Whether that revision is still the head — a stale approval is one of the
Matt W260 /// things stable change identity makes visible, so it is worth saying.
Matt W261 pub is_head: bool,
Matt W262}
Matt W263
Matt W264pub struct EventRow {
Matt W265 pub kind: String,
Matt W266 pub actor: Option<String>,
Matt W267 pub created_at: DateTime<Utc>,
Matt W268 pub payload: serde_json::Value,
Matt W269}
Matt W270
Matt W271pub struct Overview<'a> {
Matt W272 pub description_html: &'a str,
Matt W273 pub description_raw: &'a str,
Matt W274 pub comments: &'a [CommentRow],
Matt W275 /// Inline comments whose anchor no longer exists. Spec §5 step 5: surface
Matt W276 /// them in the overview timeline rather than losing them in the diff.
Matt W277 pub orphaned: &'a [CommentRow],
Matt W278 pub reviews: &'a [ReviewRow],
Matt W279 pub events: &'a [EventRow],
Matt W280 /// Whether the viewer already reviewed the head revision.
Matt W281 pub viewer_reviewed: bool,
Matt W282}
Matt W283
Matt W284pub fn overview(ctx: &RepoContext, c: &ChangeHead<'_>, o: Overview<'_>) -> Markup {
Matt W285 let base = format!("{}/changes/{}", ctx.base(), c.number);
Matt W286
Matt W287 html! {
Matt W288 @if c.synthetic {
Matt W289 div .panel {
Matt W290 div .banner {
Matt W291 "This change was authored with plain " code { "git" }
Matt W292 ". Its identity is derived from the patch, so it survives a rebase — \
Matt W293 but it is not a jj change id, and revision history is best-effort."
Matt W294 }
Matt W295 }
Matt W296 }
Matt W297
Matt W298 div .panel {
Matt W299 h2 { "Description" }
Matt W300 @if o.description_raw.is_empty() {
Matt W301 p .hint { "No description." }
Matt W302 } @else {
Matt W303 div .markdown-body { (PreEscaped(o.description_html)) }
Matt W304 }
Matt W305
Matt W306 @if c.can_manage {
Matt W307 details style="margin-top:14px" {
Matt W308 summary { "Edit" }
Matt W309 form method="post" action=(format!("{base}/edit")) .stack style="margin-top:12px" {
Matt W310 input type="hidden" name="_csrf" value=(c.csrf);
Matt W311 div .field {
Matt W312 label for="title" { "Title" }
Matt W313 input type="text" id="title" name="title" value=(c.title) required maxlength="300";
Matt W314 }
Matt W315 div .field {
Matt W316 label for="description" { "Description" }
Matt W317 textarea id="description" name="description" rows="6" { (o.description_raw) }
Matt W318 }
Matt W319 button .btn.btn-primary type="submit" { "Save" }
Matt W320 }
Matt W321 }
Matt W322 }
Matt W323 }
Matt W324
Matt W325 @if !o.reviews.is_empty() {
Matt W326 div .panel {
Matt W327 h2 { "Reviews" }
Matt W328 div .stack {
Matt W329 @for r in o.reviews {
Matt W330 div .row {
Matt W331 (verdict_badge(&r.verdict))
Matt W332 strong { (crate::views::user_link(&r.reviewer)) }
Matt W333 span .faint { (r.created_at.format("%Y-%m-%d").to_string()) }
Matt W334 span .faint .mono { (r.rev) }
Matt W335 @if !r.is_head {
Matt W336 span .chip title="left against an earlier revision of this change" {
Matt W337 "stale"
Matt W338 }
Matt W339 }
Matt W340 }
Matt W341 @if !r.body_html.is_empty() {
Matt W342 div .markdown-body style="margin:4px 0 12px 0" { (PreEscaped(&r.body_html)) }
Matt W343 }
Matt W344 }
Matt W345 }
Matt W346 }
Matt W347 }
Matt W348
Matt W349 @if !o.orphaned.is_empty() {
Matt W350 div .panel {
Matt W351 h2 { "Comments without a home" }
Matt W352 p .dim {
Matt W353 "These inline comments were anchored to code that no longer exists in \
Matt W354 the change. They are kept here rather than discarded."
Matt W355 }
Matt W356 div .stack {
Matt W357 @for cm in o.orphaned { (comment(cm, c, &base, true)) }
Matt W358 }
Matt W359 }
Matt W360 }
Matt W361
Matt W362 div .panel {
Matt W363 h2 { "Discussion" }
Matt W364 @if o.comments.is_empty() && o.events.is_empty() {
Matt W365 p .hint { "Nothing yet." }
Matt W366 }
Matt W367
Matt W368 div .stack { (timeline(o.events, o.comments, c, &base)) }
Matt W369
Matt W370 @if c.can_comment {
Matt W371 form method="post" action=(format!("{base}/comments")) .stack style="margin-top:20px" {
Matt W372 input type="hidden" name="_csrf" value=(c.csrf);
Matt W373 div .field {
Matt W374 label for="body" { "Comment" }
Matt W375 textarea id="body" name="body" rows="4" required
Matt W376 placeholder="Markdown is supported." {}
Matt W377 }
Matt W378 button .btn.btn-primary type="submit" { "Comment" }
Matt W379 }
Matt W380 } @else {
Matt W381 p .hint { "Sign in to join the discussion." }
Matt W382 }
Matt W383 }
Matt W384
Matt W385 @if c.can_comment {
Matt W386 div .panel {
Matt W387 h2 { "Review" }
Matt W388 @if o.viewer_reviewed {
Matt W389 p .hint {
Matt W390 "You have already reviewed the current revision. Submitting again \
Matt W391 records a new review against it."
Matt W392 }
Matt W393 }
Matt W394 form method="post" action=(format!("{base}/reviews")) .stack {
Matt W395 input type="hidden" name="_csrf" value=(c.csrf);
Matt W396 fieldset style="border:none;padding:0;margin:0" {
Matt W397 legend .label-condensed { "Verdict" }
Matt W398 @for (value, label) in [
Matt W399 ("comment", "Comment — no verdict"),
Matt W400 ("approve", "Approve"),
Matt W401 ("request_changes", "Request changes"),
Matt W402 ] {
Matt W403 div .field style="margin:2px 0" {
Matt W404 label {
Matt W405 input type="radio" name="verdict" value=(value)
Matt W406 checked[value == "comment"];
Matt W407 " " (label)
Matt W408 }
Matt W409 }
Matt W410 }
Matt W411 }
Matt W412 div .field {
Matt W413 label for="review_body" { "Summary" }
Matt W414 textarea id="review_body" name="body" rows="3" {}
Matt W415 }
Matt W416 button .btn.btn-primary type="submit" { "Submit review" }
Matt W417 }
Matt W418 }
Matt W419 }
Matt W420
Matt W421 @if c.can_manage {
Matt W422 (actions(c, &base))
Matt W423 }
Matt W424 }
Matt W425}
Matt W426
Matt W427/// Draft toggle, abandon/reopen, and merge.
Matt W428fn actions(c: &ChangeHead<'_>, base: &str) -> Markup {
Matt W429 html! {
Matt W430 div .panel {
Matt W431 h2 { "Actions" }
Matt W432 div .row style="gap:10px;flex-wrap:wrap" {
Matt W433 // Draft is the author's own flag and is never inferred from
Matt W434 // pushed metadata (decided; the indexer must not overwrite it).
Matt W435 form method="post" action=(format!("{base}/state")) {
Matt W436 input type="hidden" name="_csrf" value=(c.csrf);
Matt W437 input type="hidden" name="state"
Matt W438 value=(if c.state == "draft" { "open" } else { "draft" });
Matt W439 button .btn type="submit" {
Matt W440 @if c.state == "draft" { "Mark ready for review" } @else { "Convert to draft" }
Matt W441 }
Matt W442 }
Matt W443
Matt W444 @if c.state == "abandoned" {
Matt W445 form method="post" action=(format!("{base}/state")) {
Matt W446 input type="hidden" name="_csrf" value=(c.csrf);
Matt W447 input type="hidden" name="state" value="open";
Matt W448 button .btn type="submit" { "Reopen" }
Matt W449 }
Matt W450 } @else if c.state != "merged" {
Matt W451 form method="post" action=(format!("{base}/state")) {
Matt W452 input type="hidden" name="_csrf" value=(c.csrf);
Matt W453 input type="hidden" name="state" value="abandoned";
Matt W454 button .btn type="submit" { "Abandon" }
Matt W455 }
Matt W456 }
Matt W457
Matt W458 @if c.state == "open" && !c.conflicted {
Matt W459 form method="post" action=(format!("{base}/merge")) {
Matt W460 input type="hidden" name="_csrf" value=(c.csrf);
Matt W461 button .btn.btn-primary type="submit" {
Matt W462 "Merge into " (c.target_bookmark)
Matt W463 }
Matt W464 }
Matt W465 }
Matt W466 }
Matt W467 @if c.conflicted {
Matt W468 p .hint {
Matt W469 "A conflicted change cannot be merged. Resolve the conflict in your \
Matt W470 working copy and push again."
Matt W471 }
Matt W472 }
Matt W473 }
Matt W474 }
Matt W475}
Matt W476
Matt W477/// Interleave events and top-level comments in one chronological list.
Matt W478fn timeline(
Matt W479 events: &[EventRow],
Matt W480 comments: &[CommentRow],
Matt W481 c: &ChangeHead<'_>,
Matt W482 base: &str,
Matt W483) -> Markup {
Matt W484 enum Item<'a> {
Matt W485 Event(&'a EventRow),
Matt W486 Comment(&'a CommentRow),
Matt W487 }
Matt W488
Matt W489 let mut items: Vec<(DateTime<Utc>, Item<'_>)> = Vec::new();
Matt W490 items.extend(events.iter().map(|e| (e.created_at, Item::Event(e))));
Matt W491 items.extend(comments.iter().map(|cm| (cm.created_at, Item::Comment(cm))));
Matt W492 items.sort_by_key(|(t, _)| *t);
Matt W493
Matt W494 html! {
Matt W495 @for (_, item) in &items {
Matt W496 @match item {
Matt W497 Item::Comment(cm) => (comment(cm, c, base, false)),
Matt W498 Item::Event(e) => {
Matt W499 @let (glyph, colour) = event_mark(&e.kind);
Matt W500 div .timeline-event {
Matt W501 span .timeline-glyph aria-hidden="true"
Matt W502 style=(format!("color:{colour}")) { (glyph) }
Matt W503 span .faint {
Matt W504 @match event_parts(e) {
Matt W505 EventSentence::Impersonal(s) => { (s) }
Matt W506 EventSentence::By { actor, predicate, spaced } => {
Matt W507 @match actor {
Matt W508 Some(h) => (crate::views::user_link(h)),
Matt W509 None => span .faint { "someone" },
Matt W510 }
Matt W511 @if spaced { " " }
Matt W512 (predicate)
Matt W513 }
Matt W514 }
Matt W515 }
Matt W516 span .faint style="margin-left:auto" {
Matt W517 (e.created_at.format("%Y-%m-%d %H:%M").to_string())
Matt W518 }
Matt W519 }
Matt W520 }
Matt W521 }
Matt W522 }
Matt W523 }
Matt W524}
Matt W525
Matt W526/// One comment.
Matt W527pub fn comment(cm: &CommentRow, c: &ChangeHead<'_>, base: &str, show_anchor: bool) -> Markup {
Matt W528 html! {
Matt W529 div .comment id=(format!("comment-{}", cm.id)) {
Matt W530 div .row {
Matt W531 strong { (crate::views::user_link(&cm.author)) }
Matt W532 span .faint { (cm.created_at.format("%Y-%m-%d %H:%M").to_string()) }
Matt W533 @if cm.edited { span .faint { "edited" } }
Matt W534 @if cm.anchor_state == "outdated" {
Matt W535 span .chip title="the line this was written about has changed" { "outdated" }
Matt W536 }
Matt W537 @if cm.anchor_state == "orphaned" {
Matt W538 span .chip title="the code this was written about is gone" { "orphaned" }
Matt W539 }
Matt W540 @if cm.resolved { span .chip { "resolved" } }
Matt W541 }
Matt W542
Matt W543 @if show_anchor {
Matt W544 @if let Some(p) = &cm.anchor_path {
Matt W545 div .row .faint style="margin-top:4px;gap:6px" {
Matt W546 span .mono { (p) }
Matt W547 @if let Some(l) = cm.anchor_line { span { ":" (l) } }
Matt W548 }
Matt W549 }
Matt W550 @if let Some(ctx_line) = &cm.anchor_context {
Matt W551 // The original line, retained so an outdated or orphaned
Matt W552 // comment still reads as being about something (spec §5).
Matt W553 pre .anchor-context { code { (ctx_line) } }
Matt W554 }
Matt W555 }
Matt W556
Matt W557 div .comment-body.markdown-body { (PreEscaped(&cm.body_html)) }
Matt W558
Matt W559 @if c.can_manage && !cm.resolved && cm.anchor_path.is_some() {
Matt W560 form method="post" action=(format!("{base}/comments/{}/resolve", cm.id)) {
Matt W561 input type="hidden" name="_csrf" value=(c.csrf);
Matt W562 button .btn type="submit" { "Resolve" }
Matt W563 }
Matt W564 }
Matt W565 }
Matt W566 }
Matt W567}
Matt W568
Matt W569fn verdict_badge(v: &str) -> Markup {
Matt W570 html! {
Matt W571 @match v {
Matt W572 "approve" => span .badge.badge-open { "approved" },
Matt W573 "request_changes" => span .badge.badge-conflict { "changes requested" },
Matt W574 _ => span .chip { "commented" },
Matt W575 }
Matt W576 }
Matt W577}
Matt W578
Matt W579/// Human text for a timeline event.
Matt W580///
Matt W581/// Unknown kinds render their raw kind rather than being dropped: an event the
Matt W582/// UI does not recognise still happened, and hiding it would make the timeline
Matt W583/// quietly lie.
Matt W584/// Which state colour an event's kind reads as, in the timeline dot.
Matt W585///
Matt W586/// Mirrors the grouping the design uses for its timeline markers: conflict
Matt W587/// states get the conflict colour, anything that lands a change gets the
Matt W588/// merged/open colours, and routine events (pushes, rebases) stay neutral.
Matt W589/// The glyph and colour for a timeline event.
Matt W590///
Matt W591/// The same vocabulary the change list and the public feed use — `↑` pushed,
Matt W592/// `✓` reviewed, `◆` conflicted, `⤳` merged — so a reader learns four marks
Matt W593/// once and reads them everywhere.
Matt W594fn event_mark(kind: &str) -> (&'static str, &'static str) {
Matt W595 match kind {
Matt W596 "change.pushed" => ("↑", "var(--action)"),
Matt W597 "change.conflicted" => ("◆", "var(--conflict)"),
Matt W598 "change.resolved" | "change.reviewed" => ("✓", "var(--open)"),
Matt W599 "change.merged" => ("⤳", "var(--merged)"),
Matt W600 "change.opened" | "change.reopened" | "change.ready" => ("○", "var(--open)"),
Matt W601 "change.abandoned" => ("×", "var(--abandoned)"),
Matt W602 "change.rebased" => ("↻", "var(--text-dim)"),
Matt W603 _ => ("·", "var(--text-faint)"),
Matt W604 }
Matt W605}
Matt W606
Matt W607/// What an event says, with the actor split out so it can be a link.
Matt W608enum EventSentence<'a> {
Matt W609 /// Somebody did something: "<actor> merged this into main". `actor` is
Matt W610 /// `None` when the event records no account — an older row, or a push by
Matt W611 /// somebody with no Dogfood account — and reads "someone".
Matt W612 By {
Matt W613 actor: Option<&'a str>,
Matt W614 /// The rest of the sentence. Never contains a name.
Matt W615 predicate: String,
Matt W616 /// Whether to put a space before the predicate. False for the
Matt W617 /// unknown-event form, which is punctuation ("alice: some.new.kind").
Matt W618 spaced: bool,
Matt W619 },
Matt W620 /// A sentence with no subject at all: "the conflict was resolved". These
Matt W621 /// describe what became true, not who made it so, and must not acquire a
Matt W622 /// "someone" that implies an unknown person acted.
Matt W623 Impersonal(String),
Matt W624}
Matt W625
Matt W626fn event_parts(e: &EventRow) -> EventSentence<'_> {
Matt W627 let actor = e.actor.as_deref();
Matt W628 let n = |k: &str| e.payload.get(k).and_then(|v| v.as_str()).unwrap_or("");
Matt W629 let by = |predicate: String| EventSentence::By { actor, predicate, spaced: true };
Matt W630
Matt W631 match e.kind.as_str() {
Matt W632 "change.opened" => by("opened this change".into()),
Matt W633 "change.pushed" => {
Matt W634 let rev = n("rev");
Matt W635 by(if rev.is_empty() {
Matt W636 "pushed a new revision".into()
Matt W637 } else {
Matt W638 format!("pushed revision {}", df_store::abbreviate_rev(rev))
Matt W639 })
Matt W640 }
Matt W641 "change.rebased" => by("rewrote this change".into()),
Matt W642 "change.conflicted" => EventSentence::Impersonal("this change became conflicted".into()),
Matt W643 "change.resolved" => EventSentence::Impersonal("the conflict was resolved".into()),
Matt W644 "change.merged" => by(format!("merged this into {}", n("bookmark"))),
Matt W645 "change.abandoned" => by("abandoned this change".into()),
Matt W646 "change.reopened" => by("reopened this change".into()),
Matt W647 "change.drafted" => by("converted this to a draft".into()),
Matt W648 "change.ready" => by("marked this ready for review".into()),
Matt W649 "change.reviewed" => by("reviewed this change".into()),
Matt W650 "comments.rebased" => {
Matt W651 let n_out = e.payload.get("outdated").and_then(|v| v.as_i64()).unwrap_or(0);
Matt W652 let n_orph = e.payload.get("orphaned").and_then(|v| v.as_i64()).unwrap_or(0);
Matt W653 EventSentence::Impersonal(format!(
Matt W654 "comments re-anchored onto the new revision — \
Matt W655 {n_out} outdated, {n_orph} orphaned"
Matt W656 ))
Matt W657 }
Matt W658 // An event the UI does not know about still happened. Naming it beats
Matt W659 // dropping it, which would make the timeline quietly incomplete.
Matt W660 other => EventSentence::By {
Matt W661 actor,
Matt W662 predicate: format!(": {other}"),
Matt W663 spaced: false,
Matt W664 },
Matt W665 }
Matt W666}
Matt W667
Matt W668/// The whole sentence as plain text, for contexts with no markup.
Matt W669#[cfg(test)]
Matt W670fn event_text(e: &EventRow) -> String {
Matt W671 match event_parts(e) {
Matt W672 EventSentence::Impersonal(s) => s,
Matt W673 EventSentence::By { actor, predicate, spaced } => {
Matt W674 let who = actor.unwrap_or("someone");
Matt W675 if spaced {
Matt W676 format!("{who} {predicate}")
Matt W677 } else {
Matt W678 format!("{who}{predicate}")
Matt W679 }
Matt W680 }
Matt W681 }
Matt W682}
Matt W683
Matt W684// ─── files ───────────────────────────────────────────────────────────────────
Matt W685
Matt W686pub struct FilesView<'a> {
Matt W687 pub diff: Option<&'a Diff>,
Matt W688 /// Inline comments, grouped by `(path, line)` on the new side.
Matt W689 pub comments: &'a [CommentRow],
Matt W690 /// The revision being viewed.
Matt W691 pub rev: &'a str,
Matt W692 /// What it is being compared against — a revision, or the parent.
Matt W693 pub against: Option<&'a str>,
Matt W694 /// Every revision of the change, for the compare selectors.
Matt W695 pub revisions: &'a [(i32, String)],
Matt W696 /// Fold every file, for skimming the shape of a large change first.
Matt W697 pub collapsed: bool,
Matt W698}
Matt W699
Matt W700pub fn files(ctx: &RepoContext, c: &ChangeHead<'_>, f: FilesView<'_>) -> Markup {
Matt W701 let base = format!("{}/changes/{}", ctx.base(), c.number);
Matt W702
Matt W703 html! {
Matt W704 @match f.diff {
Matt W705 None => div .panel { div .empty { h2 { "Nothing to show" } p { "This change has no revisions yet." } } },
Matt W706 Some(d) => {
Matt W707 (diff_bar(d, &base, &f))
Matt W708 // The changed-file tree beside the diff, as on the commit page.
Matt W709 // A reviewer's first question on a forty-file change is "what
Matt W710 // did this touch", and the tree answers it in the shape the
Matt W711 // code actually has rather than as forty near-identical paths.
Matt W712 div .difflayout {
Matt W713 (diff::tree(d))
Matt W714 div .diffmain {
Matt W715 (diff_with_comments(d, f.comments, c, &base, f.rev, f.collapsed))
Matt W716 }
Matt W717 }
Matt W718 }
Matt W719 }
Matt W720 }
Matt W721}
Matt W722
Matt W723/// The bar that follows the reader down the diff: what is being compared, how
Matt W724/// big it is, and the two controls that change either.
Matt W725///
Matt W726/// It stays on screen because the question it answers — *which* two revisions
Matt W727/// am I looking at — is the one a reviewer loses first when scrolling a long
Matt W728/// diff, and getting it wrong means reviewing the wrong code.
Matt W729fn diff_bar(d: &Diff, base: &str, f: &FilesView<'_>) -> Markup {
Matt W730 let seq_of = |rev: &str| f.revisions.iter().find(|(_, r)| r == rev).map(|(s, _)| *s);
Matt W731 let label = |rev: &str| match seq_of(rev) {
Matt W732 Some(s) => format!("v{s}"),
Matt W733 None => df_store::abbreviate_rev(rev).to_owned(),
Matt W734 };
Matt W735
Matt W736 let comparing = match f.against {
Matt W737 Some(a) => format!("{} → {}", label(a), label(f.rev)),
Matt W738 None => format!("{} against its parent", label(f.rev)),
Matt W739 };
Matt W740
Matt W741 // Collapse/expand is a link, not a script: it survives scripting being off
Matt W742 // and it is a URL, so "here is the shape of it" can be pasted at somebody.
Matt W743 let toggle = {
Matt W744 let mut q = format!("?rev={}", f.rev);
Matt W745 if let Some(a) = f.against {
Matt W746 q.push_str(&format!("&against={a}"));
Matt W747 }
Matt W748 if !f.collapsed {
Matt W749 q.push_str("&collapse=1");
Matt W750 }
Matt W751 format!("{base}/files{q}")
Matt W752 };
Matt W753
Matt W754 html! {
Matt W755 div .diffbar {
Matt W756 (stat_summary(d))
Matt W757
Matt W758 span .spacer {}
Matt W759
Matt W760 @if !d.files.is_empty() {
Matt W761 a .diffbar-link href=(toggle) {
Matt W762 (if f.collapsed { "Expand all" } else { "Collapse all" })
Matt W763 }
Matt W764 }
Matt W765
Matt W766 // Revision-to-revision diffing (M4): compare any two revisions of
Matt W767 // the change, which is what makes "what changed since I reviewed"
Matt W768 // answerable at all. Folded away by default — the answer matters on
Matt W769 // every screen, the control only when you want a different one.
Matt W770 details .cmpbox {
Matt W771 summary { span .label-condensed { "Comparing" } (comparing) }
Matt W772 form method="get" action=(format!("{base}/files")) .cmpform {
Matt W773 label for="against" .label-condensed { "Compare" }
Matt W774 select id="against" name="against" {
Matt W775 option value="" selected[f.against.is_none()] { "against the parent" }
Matt W776 @for (seq, rev) in f.revisions {
Matt W777 option value=(rev) selected[f.against == Some(rev.as_str())] {
Matt W778 "v" (seq) " · " (df_store::abbreviate_rev(rev))
Matt W779 }
Matt W780 }
Matt W781 }
Matt W782 label for="rev" .label-condensed { "of" }
Matt W783 select id="rev" name="rev" {
Matt W784 @for (seq, rev) in f.revisions {
Matt W785 option value=(rev) selected[f.rev == rev] {
Matt W786 "v" (seq) " · " (df_store::abbreviate_rev(rev))
Matt W787 }
Matt W788 }
Matt W789 }
Matt W790 button .btn type="submit" { "Show" }
Matt W791 }
Matt W792 }
Matt W793 }
Matt W794 }
Matt W795}
Matt W796
Matt W797/// The diff, with inline comment threads under the lines they anchor to.
Matt W798pub fn diff_with_comments(
Matt W799 diff: &Diff,
Matt W800 comments: &[CommentRow],
Matt W801 c: &ChangeHead<'_>,
Matt W802 base: &str,
Matt W803 rev: &str,
Matt W804 collapsed: bool,
Matt W805) -> Markup {
Matt W806 // The comment affordance: a target link, so the form below opens with no
Matt W807 // script and the open form has a URL of its own.
Matt W808 let gutter = |file: &FileDiff, l: &DiffLine| -> Markup {
Matt W809 let Some(n) = l.new_lineno else { return html! {} };
Matt W810 if !c.can_comment {
Matt W811 return html! {};
Matt W812 }
Matt W813 let id = comment_anchor(&file.path, n);
Matt W814 html! {
Matt W815 a .dl-add href=(format!("#{id}"))
Matt W816 title=(format!("Comment on line {n}"))
Matt W817 aria-label=(format!("Comment on line {n}"))
Matt W818 { "+" }
Matt W819 }
Matt W820 };
Matt W821
Matt W822 // Threads anchored to this line, then the form to add one. Both are plain
Matt W823 // HTML, and the form is laid out only when it is the fragment target — a
Matt W824 // visible form per line is what made a large change unreadable.
Matt W825 let under = |file: &FileDiff, l: &DiffLine| -> Markup {
Matt W826 let Some(n) = l.new_lineno else { return html! {} };
Matt W827 let here: Vec<&CommentRow> = comments
Matt W828 .iter()
Matt W829 .filter(|cm| {
Matt W830 cm.anchor_path.as_deref() == Some(file.path.as_str())
Matt W831 && cm.anchor_line == Some(n as i32)
Matt W832 })
Matt W833 .collect();
Matt W834
Matt W835 html! {
Matt W836 @if !here.is_empty() {
Matt W837 tr { td colspan="4" .inline-thread {
Matt W838 @for cm in here { (comment(cm, c, base, false)) }
Matt W839 } }
Matt W840 }
Matt W841 @if c.can_comment {
Matt W842 tr .inline-form id=(comment_anchor(&file.path, n)) {
Matt W843 td colspan="4" {
Matt W844 form method="post" action=(format!("{base}/comments")) .stack {
Matt W845 input type="hidden" name="_csrf" value=(c.csrf);
Matt W846 input type="hidden" name="path" value=(file.path);
Matt W847 input type="hidden" name="line" value=(n);
Matt W848 input type="hidden" name="side" value="new";
Matt W849 input type="hidden" name="rev" value=(rev);
Matt W850 input type="hidden" name="context" value=(l.content);
Matt W851 div .label-condensed { "Comment on " (file.path) ":" (n) }
Matt W852 textarea name="body" rows="3" required {}
Matt W853 div .row {
Matt W854 button .btn.btn-primary type="submit" { "Comment" }
Matt W855 a .btn href="#" { "Cancel" }
Matt W856 }
Matt W857 }
Matt W858 }
Matt W859 }
Matt W860 }
Matt W861 }
Matt W862 };
Matt W863
Matt W864 diff::files(&DiffView {
Matt W865 diff,
Matt W866 collapsed,
Matt W867 // A change's revision is not necessarily reachable through the browse
Matt W868 // routes, so the file headers stay link-free here.
Matt W869 blob_base: None,
Matt W870 hooks: Some(LineHooks { gutter: &gutter, under: &under }),
Matt W871 })
Matt W872}
Matt W873
Matt W874/// The fragment a line's comment form answers to.
Matt W875fn comment_anchor(path: &str, line: u32) -> String {
Matt W876 format!("c-{}-{line}", path_anchor(path))
Matt W877}
Matt W878
Matt W879// ─── revisions ───────────────────────────────────────────────────────────────
Matt W880
Matt W881pub struct RevisionDetail {
Matt W882 pub seq: i32,
Matt W883 pub rev: String,
Matt W884 pub message: String,
Matt W885 pub author_name: String,
Matt W886 pub pushed_at: DateTime<Utc>,
Matt W887 pub conflicted: bool,
Matt W888 pub pushed_by: Option<String>,
Matt W889 /// Lines added and deleted against this revision's own parent.
Matt W890 pub diffstat: Option<(usize, usize)>,
Matt W891 /// The base this revision was built on, abbreviated.
Matt W892 pub base: Option<String>,
Matt W893}
Matt W894
Matt W895/// Which two revisions the interdiff compares.
Matt W896pub struct Compare<'a> {
Matt W897 pub a: i32,
Matt W898 pub b: i32,
Matt W899 /// `None` when A and B are the same revision, which has no interdiff.
Matt W900 pub diff: Option<&'a Diff>,
Matt W901}
Matt W902
Matt W903/// The revisions timeline.
Matt W904///
Matt W905/// The heart of the product. Every rewrite of a change appends a revision here,
Matt W906/// and picking any two produces the *interdiff* — what a reviewer has not seen
Matt W907/// yet. On a branch-based forge this view cannot exist: a force-push destroys
Matt W908/// the thing it would compare against.
Matt W909///
Matt W910/// A and B are chosen by link, not by script. Two query parameters, two sets of
Matt W911/// radio-styled links, and the server does the diff — so this works with
Matt W912/// scripting off and every comparison is a URL somebody can paste into a review.
Matt W913pub fn revisions(
Matt W914 ctx: &RepoContext,
Matt W915 c: &ChangeHead<'_>,
Matt W916 revs: &[RevisionDetail],
Matt W917 cmp: Compare<'_>,
Matt W918) -> Markup {
Matt W919 let base = format!("{}/changes/{}", ctx.base(), c.number);
Matt W920 let pick = |a: i32, b: i32| format!("{base}/revisions?a={a}&b={b}");
Matt W921
Matt W922 html! {
Matt W923 div .band-head {
Matt W924 h2 style="margin:0" { "Every version this change has been" }
Matt W925 span .band-note {
Matt W926 "Pick any two revisions; the interdiff is what a reviewer has not seen yet."
Matt W927 }
Matt W928 }
Matt W929
Matt W930 div .revtimeline {
Matt W931 @for (i, r) in revs.iter().enumerate().rev() {
Matt W932 @let selected = r.seq == cmp.a || r.seq == cmp.b;
Matt W933 @let colour = if r.conflicted { "var(--conflict)" } else { "var(--identity)" };
Matt W934 div .revrow .is-selected[selected] {
Matt W935 // The rail is drawn from two half-segments so the first and
Matt W936 // last rows have no line dangling past the end of the list.
Matt W937 span .revrail aria-hidden="true" {
Matt W938 span .revrail-seg
Matt W939 style=(format!("background:{}",
Matt W940 if i == revs.len() - 1 { "transparent" } else { "var(--identity)" })) {}
Matt W941 span .revdot
Matt W942 style=(format!("border-color:{colour};background:{}",
Matt W943 if selected { colour } else { "var(--bg)" })) {}
Matt W944 span .revrail-seg
Matt W945 style=(format!("background:{}",
Matt W946 if i == 0 { "transparent" } else { "var(--identity)" })) {}
Matt W947 }
Matt W948
Matt W949 div .revbody {
Matt W950 div .revline {
Matt W951 span .revlabel style=(format!("color:{colour}")) { "rev " (r.seq) }
Matt W952 span .revnote { (first_line(&r.message)) }
Matt W953 @if r.conflicted {
Matt W954 span .badge.badge-conflict {
Matt W955 span .glyph aria-hidden="true" { "◆" }
Matt W956 "conflicted"
Matt W957 }
Matt W958 }
Matt W959 span .spacer {}
Matt W960 span .revwhen
Matt W961 title=(r.pushed_at.format("%Y-%m-%d %H:%M UTC").to_string()) {
Matt W962 (r.pushed_at.format("%b %-d %H:%M").to_string())
Matt W963 }
Matt W964 }
Matt W965 div .revmeta {
Matt W966 a href=(format!("{}/tree/{}/", ctx.base(), r.rev)) {
Matt W967 "commit " (df_store::abbreviate_rev(&r.rev))
Matt W968 }
Matt W969 @if let Some(b) = &r.base {
Matt W970 span { "base " (b) }
Matt W971 }
Matt W972 @if let Some((add, del)) = r.diffstat {
Matt W973 span {
Matt W974 span .cl-add { "+" (add) }
Matt W975 " "
Matt W976 span .cl-del { "−" (del) }
Matt W977 }
Matt W978 }
Matt W979 @if let Some(p) = &r.pushed_by {
Matt W980 span { "pushed by " (crate::views::user_link(p)) }
Matt W981 } @else {
Matt W982 span { "authored by " (r.author_name) }
Matt W983 }
Matt W984 span .spacer {}
Matt W985 a .abpick .is-on[cmp.a == r.seq] href=(pick(r.seq, cmp.b))
Matt W986 title=(format!("Compare from revision {}", r.seq)) { "A" }
Matt W987 a .abpick .is-on[cmp.b == r.seq] href=(pick(cmp.a, r.seq))
Matt W988 title=(format!("Compare to revision {}", r.seq)) { "B" }
Matt W989 }
Matt W990 }
Matt W991 }
Matt W992 }
Matt W993 }
Matt W994
Matt W995 div .filediff.interdiff {
Matt W996 div .filediff-head {
Matt W997 span .label-condensed {
Matt W998 "Interdiff rev " (cmp.a) " → rev " (cmp.b)
Matt W999 }
Matt W1000 span .band-note { "what the reviewer has not seen yet" }
Matt W1001 span .spacer {}
Matt W1002 @if let Some(d) = cmp.diff {
Matt W1003 span .mono.faint {
Matt W1004 (d.files.len())
Matt W1005 @if d.files.len() == 1 { " file · " } @else { " files · " }
Matt W1006 span .cl-add { "+" (d.total_additions) }
Matt W1007 " "
Matt W1008 span .cl-del { "−" (d.total_deletions) }
Matt W1009 }
Matt W1010 }
Matt W1011 }
Matt W1012
Matt W1013 @match cmp.diff {
Matt W1014 None => {
Matt W1015 p .hint style="padding:12px" {
Matt W1016 "A and B are the same revision. Pick two different ones to see \
Matt W1017 what changed between them."
Matt W1018 }
Matt W1019 }
Matt W1020 Some(d) if d.files.is_empty() => {
Matt W1021 p .hint style="padding:12px" {
Matt W1022 "Nothing changed between these two revisions. A rebase that only \
Matt W1023 moved the change produces exactly this — which is the point."
Matt W1024 }
Matt W1025 }
Matt W1026 Some(d) => {
Matt W1027 @for f in &d.files {
Matt W1028 div .interdiff-file {
Matt W1029 div .interdiff-path {
Matt W1030 span .mono { (f.path) }
Matt W1031 span .mono.faint {
Matt W1032 span .cl-add { "+" (f.additions) }
Matt W1033 " "
Matt W1034 span .cl-del { "−" (f.deletions) }
Matt W1035 }
Matt W1036 }
Matt W1037 @for h in &f.hunks {
Matt W1038 div .diffline.diff-hunk {
Matt W1039 span .diff-ln {}
Matt W1040 span .diff-text {
Matt W1041 "@@ -" (h.old_start) "," (h.old_lines)
Matt W1042 " +" (h.new_start) "," (h.new_lines) " @@"
Matt W1043 }
Matt W1044 }
Matt W1045 @for l in &h.lines {
Matt W1046 div .diffline.(line_class(l.kind)) {
Matt W1047 span .diff-ln {
Matt W1048 @if let Some(n) = l.new_lineno.or(l.old_lineno) { (n) }
Matt W1049 }
Matt W1050 span .diff-text {
Matt W1051 // Fixed-width, so a context line's
Matt W1052 // absent sign still holds its column.
Matt W1053 span .diff-sign { (marker(l.kind)) }
Matt W1054 (spans(&l.spans, l.kind))
Matt W1055 }
Matt W1056 }
Matt W1057 }
Matt W1058 }
Matt W1059 }
Matt W1060 }
Matt W1061 @if d.truncated {
Matt W1062 p .hint style="padding:12px" {
Matt W1063 "This interdiff is too large to render in full."
Matt W1064 }
Matt W1065 }
Matt W1066 }
Matt W1067 }
Matt W1068 }
Matt W1069 }
Matt W1070}
Matt W1071
Matt W1072// ─── checks ──────────────────────────────────────────────────────────────────
Matt W1073
Matt W1074/// The Checks tab.
Matt W1075///
Matt W1076/// Dogfood has no CI integration: there is no checks table, no worker that
Matt W1077/// records results, and nothing that receives them from outside. The tab exists
Matt W1078/// because the design places it in the strip, and it says so plainly rather
Matt W1079/// than showing invented rows — a fabricated "cargo test ✓ 412 passed" on a
Matt W1080/// review page is the single most dangerous kind of placeholder, because a
Matt W1081/// reviewer would act on it.
Matt W1082pub fn checks(_ctx: &RepoContext, _c: &ChangeHead<'_>) -> Markup {
Matt W1083 html! {
Matt W1084 div .empty {
Matt W1085 h2 { "No checks are wired up" }
Matt W1086 p .measure {
Matt W1087 "This instance has no CI integration, so nothing reports check results \
Matt W1088 against a revision. Nothing is hidden here — there is genuinely no \
Matt W1089 data behind this tab yet."
Matt W1090 }
Matt W1091 p .hint.measure {
Matt W1092 "When there is, checks will run per revision rather than per branch: a \
Matt W1093 conflicted revision still runs, because a conflict is a state, not a \
Matt W1094 failure."
Matt W1095 }
Matt W1096 }
Matt W1097 }
Matt W1098}
Matt W1099
Matt W1100// ─── conflicts (M4) ──────────────────────────────────────────────────────────
Matt W1101
Matt W1102pub fn conflicts(_ctx: &RepoContext, _c: &ChangeHead<'_>, files: &[ConflictedFile]) -> Markup {
Matt W1103 html! {
Matt W1104 div .panel {
Matt W1105 h2 { "Conflicts" }
Matt W1106 p .dim {
Matt W1107 "Dogfood shows conflicts read-only. Resolve them in your working copy \
Matt W1108 with " code { "jj resolve" } " and push again — the change, this review, \
Matt W1109 and every comment on it stay where they are."
Matt W1110 }
Matt W1111
Matt W1112 @if files.is_empty() {
Matt W1113 p .hint {
Matt W1114 "The head revision is marked conflicted but no conflicted file could \
Matt W1115 be read. This usually means the conflict is structural — a delete \
Matt W1116 against a modify at the directory level."
Matt W1117 }
Matt W1118 }
Matt W1119
Matt W1120 @for f in files {
Matt W1121 div .filediff {
Matt W1122 div .row .filediff-head { span .mono { (f.path) } }
Matt W1123 div .conflict-columns {
Matt W1124 @for (label, content) in &f.sides {
Matt W1125 div .conflict-side .conflict-base[label.is_base()] {
Matt W1126 div .label-condensed { (label.label()) }
Matt W1127 @match content {
Matt W1128 // A column that does not contain the file at
Matt W1129 // all is a delete/modify conflict, and saying
Matt W1130 // so is the whole point of showing it.
Matt W1131 None => p .hint { "not present in this side" },
Matt W1132 Some(text) => pre { code { (text) } },
Matt W1133 }
Matt W1134 }
Matt W1135 }
Matt W1136 }
Matt W1137 }
Matt W1138 }
Matt W1139 }
Matt W1140 }
Matt W1141}
Matt W1142
Matt W1143// ─── stack graph (M4) ────────────────────────────────────────────────────────
Matt W1144
Matt W1145pub struct StackNode {
Matt W1146 pub number: i64,
Matt W1147 pub change_id: String,
Matt W1148 pub synthetic: bool,
Matt W1149 pub title: String,
Matt W1150 pub state: String,
Matt W1151 pub conflicted: bool,
Matt W1152 pub is_current: bool,
Matt W1153 /// Depth from the bottom of the stack, for indentation.
Matt W1154 pub depth: usize,
Matt W1155}
Matt W1156
Matt W1157/// The stack page.
Matt W1158///
Matt W1159/// One rebase moves every change in the chain, and every id survives it — so
Matt W1160/// every review, approval and permalink in the stack stays attached to the work
Matt W1161/// it was about. That sentence is the page; the rows are the evidence.
Matt W1162pub fn stack(
Matt W1163 ctx: &RepoContext,
Matt W1164 nodes: &[StackNode],
Matt W1165 change_id: &str,
Matt W1166 target: Option<&str>,
Matt W1167 csrf: &str,
Matt W1168 can_merge: bool,
Matt W1169) -> Markup {
Matt W1170 let base = ctx.base();
Matt W1171
Matt W1172 // Top of the stack first — that is how `jj log` reads, and the change a
Matt W1173 // reviewer is looking at is usually near the top.
Matt W1174 let chain: String = nodes
Matt W1175 .iter()
Matt W1176 .rev()
Matt W1177 .map(|n| n.change_id[..4.min(n.change_id.len())].to_string())
Matt W1178 .collect::<Vec<_>>()
Matt W1179 .join(" → ");
Matt W1180
Matt W1181 html! {
Matt W1182 div .page-head {
Matt W1183 h1 { "Stack" }
Matt W1184 @if nodes.len() > 1 {
Matt W1185 span .stack-chain.mono {
Matt W1186 (chain)
Matt W1187 @if let Some(t) = target { " onto " (t) }
Matt W1188 }
Matt W1189 }
Matt W1190 span .spacer {}
Matt W1191 @if nodes.len() > 1 && can_merge {
Matt W1192 form method="post" action=(format!("{base}/stacks/{change_id}/merge")) {
Matt W1193 input type="hidden" name="_csrf" value=(csrf);
Matt W1194 button .btn.btn-primary type="submit" {
Matt W1195 "Merge stack into " (target.unwrap_or("the bookmark"))
Matt W1196 }
Matt W1197 }
Matt W1198 }
Matt W1199 }
Matt W1200
Matt W1201 @if nodes.len() <= 1 {
Matt W1202 div .empty {
Matt W1203 h2 { "Not stacked" }
Matt W1204 p { "This change does not sit in a stack." }
Matt W1205 p { a .btn href=(format!("{base}/changes/{change_id}")) { "Back to the change" } }
Matt W1206 }
Matt W1207 } @else {
Matt W1208 p .dim.measure {
Matt W1209 "One rebase moves all " (nodes.len()) ". Every id survives it, so every \
Matt W1210 review, approval, and permalink in the stack stays attached to the work \
Matt W1211 it was about."
Matt W1212 }
Matt W1213
Matt W1214 div .filelist {
Matt W1215 @for n in nodes.iter().rev() {
Matt W1216 @let (glyph, colour) = match (n.conflicted, n.state.as_str()) {
Matt W1217 (true, _) => ("◆", "var(--conflict)"),
Matt W1218 (_, "merged") => ("⤳", "var(--merged)"),
Matt W1219 (_, "abandoned") => ("×", "var(--abandoned)"),
Matt W1220 _ => ("○", "var(--open)"),
Matt W1221 };
Matt W1222 a .stackrow .is-current[n.is_current]
Matt W1223 href=(format!("{base}/changes/{}", n.number)) {
Matt W1224 span .cl-indent style=(format!("width:{}px", n.depth * 10 + 8)) {}
Matt W1225 span .cl-rail aria-hidden="true" {}
Matt W1226 span .stackrow-glyph aria-hidden="true" style=(format!("color:{colour}")) {
Matt W1227 (glyph)
Matt W1228 }
Matt W1229 (change_chip(&n.change_id, n.synthetic))
Matt W1230 span .stackrow-title { (n.title) }
Matt W1231 span .spacer {}
Matt W1232 @if n.is_current {
Matt W1233 span .chip { "you are here" }
Matt W1234 }
Matt W1235 span .stackrow-meta { "#" (n.number) }
Matt W1236 }
Matt W1237 }
Matt W1238 // The base the whole chain sits on. `┴` is the same glyph
Matt W1239 // `jj log` closes a graph with.
Matt W1240 div .stackrow.stackrow-base {
Matt W1241 span .stackrow-glyph aria-hidden="true" { "┴" }
Matt W1242 @if let Some(t) = target { (t) } @else { "the target bookmark" }
Matt W1243 }
Matt W1244 }
Matt W1245
Matt W1246 div .stack-cmd.mono {
Matt W1247 "$ jj rebase -s " (&change_id[..4.min(change_id.len())])
Matt W1248 @if let Some(t) = target { " -d " (t) }
Matt W1249 }
Matt W1250
Matt W1251 p .hint.measure {
Matt W1252 "Merging the bottom of a stack lands only that change. "
Matt W1253 strong { "Merge stack" } " lands the whole chain bottom-up in one action."
Matt W1254 }
Matt W1255 }
Matt W1256 }
Matt W1257}
Matt W1258
Matt W1259// ─── helpers ─────────────────────────────────────────────────────────────────
Matt W1260
Matt W1261fn first_line(s: &str) -> &str {
Matt W1262 s.lines().next().unwrap_or("").trim()
Matt W1263}
Matt W1264
Matt W1265#[cfg(test)]
Matt W1266mod tests {
Matt W1267 use super::*;
Matt W1268
Matt W1269 fn ev(kind: &str, payload: serde_json::Value) -> EventRow {
Matt W1270 EventRow {
Matt W1271 kind: kind.into(),
Matt W1272 actor: Some("alice".into()),
Matt W1273 created_at: Utc::now(),
Matt W1274 payload,
Matt W1275 }
Matt W1276 }
Matt W1277
Matt W1278 #[test]
Matt W1279 fn known_events_read_as_sentences() {
Matt W1280 assert_eq!(
Matt W1281 event_text(&ev("change.merged", serde_json::json!({"bookmark": "main"}))),
Matt W1282 "alice merged this into main"
Matt W1283 );
Matt W1284 assert_eq!(
Matt W1285 event_text(&ev("change.opened", serde_json::json!({}))),
Matt W1286 "alice opened this change"
Matt W1287 );
Matt W1288 }
Matt W1289
Matt W1290 /// An event the UI does not know about still happened. Dropping it would
Matt W1291 /// make the timeline quietly incomplete, which is worse than an ugly line.
Matt W1292 #[test]
Matt W1293 fn unknown_events_are_shown_rather_than_hidden() {
Matt W1294 let text = event_text(&ev("something.new", serde_json::json!({})));
Matt W1295 assert!(text.contains("something.new"), "{text}");
Matt W1296 }
Matt W1297
Matt W1298 #[test]
Matt W1299 fn an_actorless_event_still_renders() {
Matt W1300 let mut e = ev("change.opened", serde_json::json!({}));
Matt W1301 e.actor = None;
Matt W1302 assert_eq!(event_text(&e), "someone opened this change");
Matt W1303 }
Matt W1304
Matt W1305 #[test]
Matt W1306 fn revision_abbreviation_goes_through_the_store() {
Matt W1307 // Spec §3 rule 2: RevId is opaque and abbreviation lives in df-store.
Matt W1308 let text = event_text(&ev(
Matt W1309 "change.pushed",
Matt W1310 serde_json::json!({"rev": "0123456789abcdef0123456789abcdef01234567"}),
Matt W1311 ));
Matt W1312 assert!(text.ends_with("0123456789ab"), "{text}");
Matt W1313 }
Matt W1314
Matt W1315 use crate::views::diff::tests::fixture as diff_fixture;
Matt W1316
Matt W1317 fn head() -> ChangeHead<'static> {
Matt W1318 ChangeHead {
Matt W1319 number: 3,
Matt W1320 change_id: "kksontuqryot",
Matt W1321 synthetic: false,
Matt W1322 title: "t",
Matt W1323 state: "open",
Matt W1324 conflicted: false,
Matt W1325 target_bookmark: "main",
Matt W1326 author: None,
Matt W1327 author_name: None,
Matt W1328 revision_count: 1,
Matt W1329 head_commit: None,
Matt W1330 created_at: Utc::now(),
Matt W1331 updated_at: Utc::now(),
Matt W1332 file_count: Some(1),
Matt W1333 comment_count: 0,
Matt W1334 can_manage: false,
Matt W1335 can_comment: true,
Matt W1336 csrf: "tok",
Matt W1337 }
Matt W1338 }
Matt W1339
Matt W1340 /// The resting diff must be code and nothing else. Every commentable line
Matt W1341 /// carries a form, but a form that is laid out costs a row per line and is
Matt W1342 /// what made a large change unreadable — so the markup is there and the
Matt W1343 /// `:target` rule is what reveals exactly one.
Matt W1344 #[test]
Matt W1345 fn a_comment_form_exists_per_line_but_none_is_open_by_default() {
Matt W1346 let d = diff_fixture(30);
Matt W1347 let html = diff_with_comments(&d, &[], &head(), "/o/r/changes/3", "abc", false).into_string();
Matt W1348
Matt W1349 assert_eq!(html.matches("class=\"inline-form\"").count(), 30);
Matt W1350 // Nothing renders the old always-visible summary chrome any more.
Matt W1351 assert!(!html.contains("Comment on line 1<"), "per-line chrome is back");
Matt W1352 // The affordance and the form agree on the fragment.
Matt W1353 let anchor = path_anchor("crates/df-web/src/views/review.rs");
Matt W1354 assert!(html.contains(&format!("href=\"#c-{anchor}-7\"")));
Matt W1355 assert!(html.contains(&format!("id=\"c-{anchor}-7\"")));
Matt W1356 }
Matt W1357
Matt W1358 /// A reader with no comment rights gets the diff and nothing else — no
Matt W1359 /// dead affordance, and none of the per-line form markup either.
Matt W1360 #[test]
Matt W1361 fn a_reader_who_cannot_comment_gets_no_forms() {
Matt W1362 let d = diff_fixture(9);
Matt W1363 let head = ChangeHead { can_comment: false, ..head() };
Matt W1364 let html = diff_with_comments(&d, &[], &head, "/b", "abc", false).into_string();
Matt W1365
Matt W1366 assert!(!html.contains("inline-form"));
Matt W1367 assert!(!html.contains("dl-add"));
Matt W1368 // The diff itself is still there.
Matt W1369 assert!(html.contains("difftable"));
Matt W1370 }
Matt W1371}

1371 lines · Rust