Jump to…
snowfix: 500 error because of database is patchedyuzpxzopsouq1mo
1//! Change detail: overview, files, revisions, conflicts, and the stack graph
2//! (M3, M4).
3//!
4//! Everything here renders standalone. The comment forms are ordinary POSTs to
5//! ordinary URLs, so a reviewer with JavaScript disabled can read a diff and
6//! leave an inline comment — which spec §7 makes a hard requirement, and which
7//! is the single easiest thing to lose when a diff view grows interactive.
8//!
9//! htmx enhances two things and nothing else: posting a comment swaps just its
10//! thread, and expanding collapsed context fetches only the extra lines.
11
12use chrono::{DateTime, Utc};
13use maud::{html, Markup, PreEscaped};
14use uuid::Uuid;
15
16use df_store::{ConflictedFile, Diff, DiffLine, FileDiff};
17
18use crate::repo_ctx::RepoContext;
19use crate::views::change::{change_chip, state_badge};
20use crate::views::diff::{
21 self, line_class, marker, path_anchor, spans, stat_summary, DiffView, LineHooks,
22};
23
24// ─── shared shape ────────────────────────────────────────────────────────────
25
26/// Everything the change pages need about the change itself.
27pub struct ChangeHead<'a> {
28 pub number: i64,
29 pub change_id: &'a str,
30 pub synthetic: bool,
31 pub title: &'a str,
32 pub state: &'a str,
33 pub conflicted: bool,
34 pub target_bookmark: &'a str,
35 pub author: Option<&'a str>,
36 /// The name the commit itself carries, used when no account matched.
37 pub author_name: Option<&'a str>,
38 pub revision_count: usize,
39 /// The head revision's commit id, abbreviated.
40 pub head_commit: Option<&'a str>,
41 pub created_at: DateTime<Utc>,
42 pub updated_at: DateTime<Utc>,
43 pub file_count: Option<usize>,
44 pub comment_count: i64,
45 /// Whether the viewer may edit the change (author or maintainer).
46 pub can_manage: bool,
47 pub can_comment: bool,
48 pub csrf: &'a str,
49}
50
51/// One node of the stack rail in the aside.
52pub struct StackNodeMini {
53 pub change_id: String,
54 pub number: i64,
55 pub state: String,
56 pub conflicted: bool,
57 pub depth: usize,
58 pub is_current: bool,
59}
60
61/// The right-hand column, identical on every change tab.
62pub struct ChangeAside {
63 pub reviewers: Vec<crate::views::change::Reviewer>,
64 pub stack: Vec<StackNodeMini>,
65}
66
67/// The change header: the id at display size, the state, and the tabs.
68///
69/// The change id is the largest thing on the page — larger than the title.
70/// That is deliberate and it is the argument the product is making: the title
71/// is prose somebody typed and can retype, the id is what every review,
72/// approval and permalink is attached to, and it does not change when the
73/// commit underneath it does.
74pub fn header(ctx: &RepoContext, c: &ChangeHead<'_>, tab: &str) -> Markup {
75 let base = format!("{}/changes/{}", ctx.base(), c.number);
76
77 html! {
78 div .change-head {
79 div .change-head-top {
80 span .change-head-rail aria-hidden="true" {}
81 div .change-head-id {
82 div .change-head-line {
83 @if c.synthetic {
84 span .change-id-display.is-synthetic
85 title="Authored with plain git — identity derived from the patch" {
86 (&c.change_id[..12.min(c.change_id.len())])
87 }
88 } @else {
89 span .change-id-display title=(format!("jj change id: {}", c.change_id)) {
90 (crate::views::repo::cid_parts(c.change_id))
91 }
92 }
93 (state_badge(c.state, c.conflicted))
94 span .faint.mono { "#" (c.number) }
95 }
96 h1 .change-title { (c.title) }
97 div .change-byline {
98 @if c.author.is_some() || c.author_name.is_some() {
99 span { (crate::views::person(c.author, c.author_name)) " →" }
100 }
101 span .chip { (c.target_bookmark) }
102 span .sep aria-hidden="true" { "·" }
103 span { (revision_span(c)) }
104 @if let Some(h) = c.head_commit {
105 span .sep aria-hidden="true" { "·" }
106 span { "commit " span .mono.faint { (h) } }
107 }
108 }
109 }
110 }
111
112 nav .subtabs.ruled aria-label="Change sections" {
113 a href=(base.clone()) .active[tab == "overview"]
114 aria-current=[(tab == "overview").then_some("page")] {
115 "Overview"
116 @if c.comment_count > 0 {
117 span .tab-count { (c.comment_count) }
118 }
119 }
120 a href=(format!("{base}/files")) .active[tab == "files"]
121 aria-current=[(tab == "files").then_some("page")] {
122 "Files"
123 @if let Some(n) = c.file_count {
124 span .tab-count { (n) }
125 }
126 }
127 a href=(format!("{base}/revisions")) .active[tab == "revisions"]
128 aria-current=[(tab == "revisions").then_some("page")] {
129 "Revisions"
130 @if c.revision_count > 1 {
131 span .tab-count { (c.revision_count) }
132 }
133 }
134 a href=(format!("{base}/checks")) .active[tab == "checks"]
135 aria-current=[(tab == "checks").then_some("page")] { "Checks" }
136 @if c.conflicted {
137 a href=(format!("{base}/conflicts")) .active[tab == "conflicts"]
138 aria-current=[(tab == "conflicts").then_some("page")] { "Conflicts" }
139 }
140 a href=(format!("{}/stacks/{}", ctx.base(), c.change_id)) { "Stack" }
141 }
142 }
143 }
144}
145
146/// "4 revisions over 3 days" — the sentence stable identity makes possible.
147///
148/// A single-revision change gets "1 revision" and no span: "over 0 days" is
149/// noise, and the interesting number is the one that says this change has been
150/// rewritten and kept its name.
151fn revision_span(c: &ChangeHead<'_>) -> String {
152 let n = c.revision_count;
153 let unit = if n == 1 { "revision" } else { "revisions" };
154 let days = (c.updated_at - c.created_at).num_days();
155
156 match (n, days) {
157 (1, _) => format!("1 {unit}"),
158 (_, 0) => format!("{n} {unit}"),
159 (_, 1) => format!("{n} {unit} over a day"),
160 (_, d) => format!("{n} {unit} over {d} days"),
161 }
162}
163
164/// Wrap a change tab's body in the two-column shell with the aside.
165pub fn tab_body(ctx: &RepoContext, aside: &ChangeAside, body: Markup) -> Markup {
166 html! {
167 div .columns.columns-repo {
168 div .columns-main { (body) }
169 aside .columns-aside.is-sticky {
170 @if !aside.reviewers.is_empty() {
171 div .aside-block {
172 div .label-condensed { "Reviewers" }
173 @for rv in &aside.reviewers {
174 (reviewer_line(rv))
175 }
176 }
177 }
178 @if aside.stack.len() > 1 {
179 div .aside-block {
180 div .label-condensed { "Stack" }
181 @for n in &aside.stack {
182 (stack_rail_row(ctx, n))
183 }
184 }
185 }
186 }
187 }
188 }
189}
190
191fn reviewer_line(rv: &crate::views::change::Reviewer) -> Markup {
192 let (glyph, colour, meta, meta_colour) = match (rv.verdict.as_str(), rv.at_head) {
193 ("approved", true) => ("✓", "var(--open)", "approved", "var(--text-faint)"),
194 // A stale approval is the thing stable identity makes visible. It gets
195 // the conflict colour because it is a state the author has to act on,
196 // not a verdict they can bank.
197 ("approved", false) => ("✓", "var(--open)", "stale", "var(--conflict)"),
198 ("rejected", _) => ("×", "var(--danger)", "changes requested", "var(--danger)"),
199 _ => ("○", "var(--text-faint)", "commented", "var(--text-faint)"),
200 };
201
202 html! {
203 div .reviewer-line {
204 span .reviewer-glyph aria-hidden="true" style=(format!("color:{colour}")) { (glyph) }
205 (crate::views::user_link(&rv.handle))
206 span .spacer {}
207 span .reviewer-meta style=(format!("color:{meta_colour}")) { (meta) }
208 }
209 }
210}
211
212fn stack_rail_row(ctx: &RepoContext, n: &StackNodeMini) -> Markup {
213 let (glyph, colour) = match (n.conflicted, n.state.as_str()) {
214 (true, _) => ("◆", "var(--conflict)"),
215 (_, "merged") => ("⤳", "var(--merged)"),
216 (_, "abandoned") => ("×", "var(--abandoned)"),
217 _ => ("○", "var(--open)"),
218 };
219
220 html! {
221 a .mini-row .is-current[n.is_current]
222 href=(format!("{}/changes/{}", ctx.base(), n.number)) {
223 span .cl-indent style=(format!("width:{}px", n.depth * 6)) {}
224 span .mini-rail aria-hidden="true" {}
225 span .mini-glyph aria-hidden="true" style=(format!("color:{colour}")) { (glyph) }
226 (change_chip(&n.change_id, false))
227 }
228 }
229}
230
231// ─── overview ────────────────────────────────────────────────────────────────
232
233pub struct CommentRow {
234 pub id: Uuid,
235 pub author: String,
236 pub body_html: String,
237 pub created_at: DateTime<Utc>,
238 pub edited: bool,
239 pub anchor_path: Option<String>,
240 pub anchor_line: Option<i32>,
241 /// `old` or `new`. Stored and loaded because a comment on a deleted line is
242 /// a different thing from one on an added line, and losing that on the way
243 /// in would be unrecoverable — the diff view currently anchors on the new
244 /// side only, so nothing reads it yet.
245 #[allow(dead_code)]
246 pub anchor_side: Option<String>,
247 pub anchor_state: String,
248 pub anchor_context: Option<String>,
249 pub resolved: bool,
250}
251
252pub struct ReviewRow {
253 pub reviewer: String,
254 pub verdict: String,
255 pub body_html: String,
256 pub created_at: DateTime<Utc>,
257 /// The revision the review was left against, abbreviated.
258 pub rev: String,
259 /// Whether that revision is still the head — a stale approval is one of the
260 /// things stable change identity makes visible, so it is worth saying.
261 pub is_head: bool,
262}
263
264pub struct EventRow {
265 pub kind: String,
266 pub actor: Option<String>,
267 pub created_at: DateTime<Utc>,
268 pub payload: serde_json::Value,
269}
270
271pub struct Overview<'a> {
272 pub description_html: &'a str,
273 pub description_raw: &'a str,
274 pub comments: &'a [CommentRow],
275 /// Inline comments whose anchor no longer exists. Spec §5 step 5: surface
276 /// them in the overview timeline rather than losing them in the diff.
277 pub orphaned: &'a [CommentRow],
278 pub reviews: &'a [ReviewRow],
279 pub events: &'a [EventRow],
280 /// Whether the viewer already reviewed the head revision.
281 pub viewer_reviewed: bool,
282}
283
284pub fn overview(ctx: &RepoContext, c: &ChangeHead<'_>, o: Overview<'_>) -> Markup {
285 let base = format!("{}/changes/{}", ctx.base(), c.number);
286
287 html! {
288 @if c.synthetic {
289 div .panel {
290 div .banner {
291 "This change was authored with plain " code { "git" }
292 ". Its identity is derived from the patch, so it survives a rebase — \
293 but it is not a jj change id, and revision history is best-effort."
294 }
295 }
296 }
297
298 div .panel {
299 h2 { "Description" }
300 @if o.description_raw.is_empty() {
301 p .hint { "No description." }
302 } @else {
303 div .markdown-body { (PreEscaped(o.description_html)) }
304 }
305
306 @if c.can_manage {
307 details style="margin-top:14px" {
308 summary { "Edit" }
309 form method="post" action=(format!("{base}/edit")) .stack style="margin-top:12px" {
310 input type="hidden" name="_csrf" value=(c.csrf);
311 div .field {
312 label for="title" { "Title" }
313 input type="text" id="title" name="title" value=(c.title) required maxlength="300";
314 }
315 div .field {
316 label for="description" { "Description" }
317 textarea id="description" name="description" rows="6" { (o.description_raw) }
318 }
319 button .btn.btn-primary type="submit" { "Save" }
320 }
321 }
322 }
323 }
324
325 @if !o.reviews.is_empty() {
326 div .panel {
327 h2 { "Reviews" }
328 div .stack {
329 @for r in o.reviews {
330 div .row {
331 (verdict_badge(&r.verdict))
332 strong { (crate::views::user_link(&r.reviewer)) }
333 span .faint { (r.created_at.format("%Y-%m-%d").to_string()) }
334 span .faint .mono { (r.rev) }
335 @if !r.is_head {
336 span .chip title="left against an earlier revision of this change" {
337 "stale"
338 }
339 }
340 }
341 @if !r.body_html.is_empty() {
342 div .markdown-body style="margin:4px 0 12px 0" { (PreEscaped(&r.body_html)) }
343 }
344 }
345 }
346 }
347 }
348
349 @if !o.orphaned.is_empty() {
350 div .panel {
351 h2 { "Comments without a home" }
352 p .dim {
353 "These inline comments were anchored to code that no longer exists in \
354 the change. They are kept here rather than discarded."
355 }
356 div .stack {
357 @for cm in o.orphaned { (comment(cm, c, &base, true)) }
358 }
359 }
360 }
361
362 div .panel {
363 h2 { "Discussion" }
364 @if o.comments.is_empty() && o.events.is_empty() {
365 p .hint { "Nothing yet." }
366 }
367
368 div .stack { (timeline(o.events, o.comments, c, &base)) }
369
370 @if c.can_comment {
371 form method="post" action=(format!("{base}/comments")) .stack style="margin-top:20px" {
372 input type="hidden" name="_csrf" value=(c.csrf);
373 div .field {
374 label for="body" { "Comment" }
375 textarea id="body" name="body" rows="4" required
376 placeholder="Markdown is supported." {}
377 }
378 button .btn.btn-primary type="submit" { "Comment" }
379 }
380 } @else {
381 p .hint { "Sign in to join the discussion." }
382 }
383 }
384
385 @if c.can_comment {
386 div .panel {
387 h2 { "Review" }
388 @if o.viewer_reviewed {
389 p .hint {
390 "You have already reviewed the current revision. Submitting again \
391 records a new review against it."
392 }
393 }
394 form method="post" action=(format!("{base}/reviews")) .stack {
395 input type="hidden" name="_csrf" value=(c.csrf);
396 fieldset style="border:none;padding:0;margin:0" {
397 legend .label-condensed { "Verdict" }
398 @for (value, label) in [
399 ("comment", "Comment — no verdict"),
400 ("approve", "Approve"),
401 ("request_changes", "Request changes"),
402 ] {
403 div .field style="margin:2px 0" {
404 label {
405 input type="radio" name="verdict" value=(value)
406 checked[value == "comment"];
407 " " (label)
408 }
409 }
410 }
411 }
412 div .field {
413 label for="review_body" { "Summary" }
414 textarea id="review_body" name="body" rows="3" {}
415 }
416 button .btn.btn-primary type="submit" { "Submit review" }
417 }
418 }
419 }
420
421 @if c.can_manage {
422 (actions(c, &base))
423 }
424 }
425}
426
427/// Draft toggle, abandon/reopen, and merge.
428fn actions(c: &ChangeHead<'_>, base: &str) -> Markup {
429 html! {
430 div .panel {
431 h2 { "Actions" }
432 div .row style="gap:10px;flex-wrap:wrap" {
433 // Draft is the author's own flag and is never inferred from
434 // pushed metadata (decided; the indexer must not overwrite it).
435 form method="post" action=(format!("{base}/state")) {
436 input type="hidden" name="_csrf" value=(c.csrf);
437 input type="hidden" name="state"
438 value=(if c.state == "draft" { "open" } else { "draft" });
439 button .btn type="submit" {
440 @if c.state == "draft" { "Mark ready for review" } @else { "Convert to draft" }
441 }
442 }
443
444 @if c.state == "abandoned" {
445 form method="post" action=(format!("{base}/state")) {
446 input type="hidden" name="_csrf" value=(c.csrf);
447 input type="hidden" name="state" value="open";
448 button .btn type="submit" { "Reopen" }
449 }
450 } @else if c.state != "merged" {
451 form method="post" action=(format!("{base}/state")) {
452 input type="hidden" name="_csrf" value=(c.csrf);
453 input type="hidden" name="state" value="abandoned";
454 button .btn type="submit" { "Abandon" }
455 }
456 }
457
458 @if c.state == "open" && !c.conflicted {
459 form method="post" action=(format!("{base}/merge")) {
460 input type="hidden" name="_csrf" value=(c.csrf);
461 button .btn.btn-primary type="submit" {
462 "Merge into " (c.target_bookmark)
463 }
464 }
465 }
466 }
467 @if c.conflicted {
468 p .hint {
469 "A conflicted change cannot be merged. Resolve the conflict in your \
470 working copy and push again."
471 }
472 }
473 }
474 }
475}
476
477/// Interleave events and top-level comments in one chronological list.
478fn timeline(
479 events: &[EventRow],
480 comments: &[CommentRow],
481 c: &ChangeHead<'_>,
482 base: &str,
483) -> Markup {
484 enum Item<'a> {
485 Event(&'a EventRow),
486 Comment(&'a CommentRow),
487 }
488
489 let mut items: Vec<(DateTime<Utc>, Item<'_>)> = Vec::new();
490 items.extend(events.iter().map(|e| (e.created_at, Item::Event(e))));
491 items.extend(comments.iter().map(|cm| (cm.created_at, Item::Comment(cm))));
492 items.sort_by_key(|(t, _)| *t);
493
494 html! {
495 @for (_, item) in &items {
496 @match item {
497 Item::Comment(cm) => (comment(cm, c, base, false)),
498 Item::Event(e) => {
499 @let (glyph, colour) = event_mark(&e.kind);
500 div .timeline-event {
501 span .timeline-glyph aria-hidden="true"
502 style=(format!("color:{colour}")) { (glyph) }
503 span .faint {
504 @match event_parts(e) {
505 EventSentence::Impersonal(s) => { (s) }
506 EventSentence::By { actor, predicate, spaced } => {
507 @match actor {
508 Some(h) => (crate::views::user_link(h)),
509 None => span .faint { "someone" },
510 }
511 @if spaced { " " }
512 (predicate)
513 }
514 }
515 }
516 span .faint style="margin-left:auto" {
517 (e.created_at.format("%Y-%m-%d %H:%M").to_string())
518 }
519 }
520 }
521 }
522 }
523 }
524}
525
526/// One comment.
527pub fn comment(cm: &CommentRow, c: &ChangeHead<'_>, base: &str, show_anchor: bool) -> Markup {
528 html! {
529 div .comment id=(format!("comment-{}", cm.id)) {
530 div .row {
531 strong { (crate::views::user_link(&cm.author)) }
532 span .faint { (cm.created_at.format("%Y-%m-%d %H:%M").to_string()) }
533 @if cm.edited { span .faint { "edited" } }
534 @if cm.anchor_state == "outdated" {
535 span .chip title="the line this was written about has changed" { "outdated" }
536 }
537 @if cm.anchor_state == "orphaned" {
538 span .chip title="the code this was written about is gone" { "orphaned" }
539 }
540 @if cm.resolved { span .chip { "resolved" } }
541 }
542
543 @if show_anchor {
544 @if let Some(p) = &cm.anchor_path {
545 div .row .faint style="margin-top:4px;gap:6px" {
546 span .mono { (p) }
547 @if let Some(l) = cm.anchor_line { span { ":" (l) } }
548 }
549 }
550 @if let Some(ctx_line) = &cm.anchor_context {
551 // The original line, retained so an outdated or orphaned
552 // comment still reads as being about something (spec §5).
553 pre .anchor-context { code { (ctx_line) } }
554 }
555 }
556
557 div .comment-body.markdown-body { (PreEscaped(&cm.body_html)) }
558
559 @if c.can_manage && !cm.resolved && cm.anchor_path.is_some() {
560 form method="post" action=(format!("{base}/comments/{}/resolve", cm.id)) {
561 input type="hidden" name="_csrf" value=(c.csrf);
562 button .btn type="submit" { "Resolve" }
563 }
564 }
565 }
566 }
567}
568
569fn verdict_badge(v: &str) -> Markup {
570 html! {
571 @match v {
572 "approve" => span .badge.badge-open { "approved" },
573 "request_changes" => span .badge.badge-conflict { "changes requested" },
574 _ => span .chip { "commented" },
575 }
576 }
577}
578
579/// Human text for a timeline event.
580///
581/// Unknown kinds render their raw kind rather than being dropped: an event the
582/// UI does not recognise still happened, and hiding it would make the timeline
583/// quietly lie.
584/// Which state colour an event's kind reads as, in the timeline dot.
585///
586/// Mirrors the grouping the design uses for its timeline markers: conflict
587/// states get the conflict colour, anything that lands a change gets the
588/// merged/open colours, and routine events (pushes, rebases) stay neutral.
589/// The glyph and colour for a timeline event.
590///
591/// The same vocabulary the change list and the public feed use — `↑` pushed,
592/// `✓` reviewed, `◆` conflicted, `⤳` merged — so a reader learns four marks
593/// once and reads them everywhere.
594fn event_mark(kind: &str) -> (&'static str, &'static str) {
595 match kind {
596 "change.pushed" => ("↑", "var(--action)"),
597 "change.conflicted" => ("◆", "var(--conflict)"),
598 "change.resolved" | "change.reviewed" => ("✓", "var(--open)"),
599 "change.merged" => ("⤳", "var(--merged)"),
600 "change.opened" | "change.reopened" | "change.ready" => ("○", "var(--open)"),
601 "change.abandoned" => ("×", "var(--abandoned)"),
602 "change.rebased" => ("↻", "var(--text-dim)"),
603 _ => ("·", "var(--text-faint)"),
604 }
605}
606
607/// What an event says, with the actor split out so it can be a link.
608enum EventSentence<'a> {
609 /// Somebody did something: "<actor> merged this into main". `actor` is
610 /// `None` when the event records no account — an older row, or a push by
611 /// somebody with no Dogfood account — and reads "someone".
612 By {
613 actor: Option<&'a str>,
614 /// The rest of the sentence. Never contains a name.
615 predicate: String,
616 /// Whether to put a space before the predicate. False for the
617 /// unknown-event form, which is punctuation ("alice: some.new.kind").
618 spaced: bool,
619 },
620 /// A sentence with no subject at all: "the conflict was resolved". These
621 /// describe what became true, not who made it so, and must not acquire a
622 /// "someone" that implies an unknown person acted.
623 Impersonal(String),
624}
625
626fn event_parts(e: &EventRow) -> EventSentence<'_> {
627 let actor = e.actor.as_deref();
628 let n = |k: &str| e.payload.get(k).and_then(|v| v.as_str()).unwrap_or("");
629 let by = |predicate: String| EventSentence::By { actor, predicate, spaced: true };
630
631 match e.kind.as_str() {
632 "change.opened" => by("opened this change".into()),
633 "change.pushed" => {
634 let rev = n("rev");
635 by(if rev.is_empty() {
636 "pushed a new revision".into()
637 } else {
638 format!("pushed revision {}", df_store::abbreviate_rev(rev))
639 })
640 }
641 "change.rebased" => by("rewrote this change".into()),
642 "change.conflicted" => EventSentence::Impersonal("this change became conflicted".into()),
643 "change.resolved" => EventSentence::Impersonal("the conflict was resolved".into()),
644 "change.merged" => by(format!("merged this into {}", n("bookmark"))),
645 "change.abandoned" => by("abandoned this change".into()),
646 "change.reopened" => by("reopened this change".into()),
647 "change.drafted" => by("converted this to a draft".into()),
648 "change.ready" => by("marked this ready for review".into()),
649 "change.reviewed" => by("reviewed this change".into()),
650 "comments.rebased" => {
651 let n_out = e.payload.get("outdated").and_then(|v| v.as_i64()).unwrap_or(0);
652 let n_orph = e.payload.get("orphaned").and_then(|v| v.as_i64()).unwrap_or(0);
653 EventSentence::Impersonal(format!(
654 "comments re-anchored onto the new revision — \
655 {n_out} outdated, {n_orph} orphaned"
656 ))
657 }
658 // An event the UI does not know about still happened. Naming it beats
659 // dropping it, which would make the timeline quietly incomplete.
660 other => EventSentence::By {
661 actor,
662 predicate: format!(": {other}"),
663 spaced: false,
664 },
665 }
666}
667
668/// The whole sentence as plain text, for contexts with no markup.
669#[cfg(test)]
670fn event_text(e: &EventRow) -> String {
671 match event_parts(e) {
672 EventSentence::Impersonal(s) => s,
673 EventSentence::By { actor, predicate, spaced } => {
674 let who = actor.unwrap_or("someone");
675 if spaced {
676 format!("{who} {predicate}")
677 } else {
678 format!("{who}{predicate}")
679 }
680 }
681 }
682}
683
684// ─── files ───────────────────────────────────────────────────────────────────
685
686pub struct FilesView<'a> {
687 pub diff: Option<&'a Diff>,
688 /// Inline comments, grouped by `(path, line)` on the new side.
689 pub comments: &'a [CommentRow],
690 /// The revision being viewed.
691 pub rev: &'a str,
692 /// What it is being compared against — a revision, or the parent.
693 pub against: Option<&'a str>,
694 /// Every revision of the change, for the compare selectors.
695 pub revisions: &'a [(i32, String)],
696 /// Fold every file, for skimming the shape of a large change first.
697 pub collapsed: bool,
698}
699
700pub fn files(ctx: &RepoContext, c: &ChangeHead<'_>, f: FilesView<'_>) -> Markup {
701 let base = format!("{}/changes/{}", ctx.base(), c.number);
702
703 html! {
704 @match f.diff {
705 None => div .panel { div .empty { h2 { "Nothing to show" } p { "This change has no revisions yet." } } },
706 Some(d) => {
707 (diff_bar(d, &base, &f))
708 // The changed-file tree beside the diff, as on the commit page.
709 // A reviewer's first question on a forty-file change is "what
710 // did this touch", and the tree answers it in the shape the
711 // code actually has rather than as forty near-identical paths.
712 div .difflayout {
713 (diff::tree(d))
714 div .diffmain {
715 (diff_with_comments(d, f.comments, c, &base, f.rev, f.collapsed))
716 }
717 }
718 }
719 }
720 }
721}
722
723/// The bar that follows the reader down the diff: what is being compared, how
724/// big it is, and the two controls that change either.
725///
726/// It stays on screen because the question it answers — *which* two revisions
727/// am I looking at — is the one a reviewer loses first when scrolling a long
728/// diff, and getting it wrong means reviewing the wrong code.
729fn diff_bar(d: &Diff, base: &str, f: &FilesView<'_>) -> Markup {
730 let seq_of = |rev: &str| f.revisions.iter().find(|(_, r)| r == rev).map(|(s, _)| *s);
731 let label = |rev: &str| match seq_of(rev) {
732 Some(s) => format!("v{s}"),
733 None => df_store::abbreviate_rev(rev).to_owned(),
734 };
735
736 let comparing = match f.against {
737 Some(a) => format!("{} → {}", label(a), label(f.rev)),
738 None => format!("{} against its parent", label(f.rev)),
739 };
740
741 // Collapse/expand is a link, not a script: it survives scripting being off
742 // and it is a URL, so "here is the shape of it" can be pasted at somebody.
743 let toggle = {
744 let mut q = format!("?rev={}", f.rev);
745 if let Some(a) = f.against {
746 q.push_str(&format!("&against={a}"));
747 }
748 if !f.collapsed {
749 q.push_str("&collapse=1");
750 }
751 format!("{base}/files{q}")
752 };
753
754 html! {
755 div .diffbar {
756 (stat_summary(d))
757
758 span .spacer {}
759
760 @if !d.files.is_empty() {
761 a .diffbar-link href=(toggle) {
762 (if f.collapsed { "Expand all" } else { "Collapse all" })
763 }
764 }
765
766 // Revision-to-revision diffing (M4): compare any two revisions of
767 // the change, which is what makes "what changed since I reviewed"
768 // answerable at all. Folded away by default — the answer matters on
769 // every screen, the control only when you want a different one.
770 details .cmpbox {
771 summary { span .label-condensed { "Comparing" } (comparing) }
772 form method="get" action=(format!("{base}/files")) .cmpform {
773 label for="against" .label-condensed { "Compare" }
774 select id="against" name="against" {
775 option value="" selected[f.against.is_none()] { "against the parent" }
776 @for (seq, rev) in f.revisions {
777 option value=(rev) selected[f.against == Some(rev.as_str())] {
778 "v" (seq) " · " (df_store::abbreviate_rev(rev))
779 }
780 }
781 }
782 label for="rev" .label-condensed { "of" }
783 select id="rev" name="rev" {
784 @for (seq, rev) in f.revisions {
785 option value=(rev) selected[f.rev == rev] {
786 "v" (seq) " · " (df_store::abbreviate_rev(rev))
787 }
788 }
789 }
790 button .btn type="submit" { "Show" }
791 }
792 }
793 }
794 }
795}
796
797/// The diff, with inline comment threads under the lines they anchor to.
798pub fn diff_with_comments(
799 diff: &Diff,
800 comments: &[CommentRow],
801 c: &ChangeHead<'_>,
802 base: &str,
803 rev: &str,
804 collapsed: bool,
805) -> Markup {
806 // The comment affordance: a target link, so the form below opens with no
807 // script and the open form has a URL of its own.
808 let gutter = |file: &FileDiff, l: &DiffLine| -> Markup {
809 let Some(n) = l.new_lineno else { return html! {} };
810 if !c.can_comment {
811 return html! {};
812 }
813 let id = comment_anchor(&file.path, n);
814 html! {
815 a .dl-add href=(format!("#{id}"))
816 title=(format!("Comment on line {n}"))
817 aria-label=(format!("Comment on line {n}"))
818 { "+" }
819 }
820 };
821
822 // Threads anchored to this line, then the form to add one. Both are plain
823 // HTML, and the form is laid out only when it is the fragment target — a
824 // visible form per line is what made a large change unreadable.
825 let under = |file: &FileDiff, l: &DiffLine| -> Markup {
826 let Some(n) = l.new_lineno else { return html! {} };
827 let here: Vec<&CommentRow> = comments
828 .iter()
829 .filter(|cm| {
830 cm.anchor_path.as_deref() == Some(file.path.as_str())
831 && cm.anchor_line == Some(n as i32)
832 })
833 .collect();
834
835 html! {
836 @if !here.is_empty() {
837 tr { td colspan="4" .inline-thread {
838 @for cm in here { (comment(cm, c, base, false)) }
839 } }
840 }
841 @if c.can_comment {
842 tr .inline-form id=(comment_anchor(&file.path, n)) {
843 td colspan="4" {
844 form method="post" action=(format!("{base}/comments")) .stack {
845 input type="hidden" name="_csrf" value=(c.csrf);
846 input type="hidden" name="path" value=(file.path);
847 input type="hidden" name="line" value=(n);
848 input type="hidden" name="side" value="new";
849 input type="hidden" name="rev" value=(rev);
850 input type="hidden" name="context" value=(l.content);
851 div .label-condensed { "Comment on " (file.path) ":" (n) }
852 textarea name="body" rows="3" required {}
853 div .row {
854 button .btn.btn-primary type="submit" { "Comment" }
855 a .btn href="#" { "Cancel" }
856 }
857 }
858 }
859 }
860 }
861 }
862 };
863
864 diff::files(&DiffView {
865 diff,
866 collapsed,
867 // A change's revision is not necessarily reachable through the browse
868 // routes, so the file headers stay link-free here.
869 blob_base: None,
870 hooks: Some(LineHooks { gutter: &gutter, under: &under }),
871 })
872}
873
874/// The fragment a line's comment form answers to.
875fn comment_anchor(path: &str, line: u32) -> String {
876 format!("c-{}-{line}", path_anchor(path))
877}
878
879// ─── revisions ───────────────────────────────────────────────────────────────
880
881pub struct RevisionDetail {
882 pub seq: i32,
883 pub rev: String,
884 pub message: String,
885 pub author_name: String,
886 pub pushed_at: DateTime<Utc>,
887 pub conflicted: bool,
888 pub pushed_by: Option<String>,
889 /// Lines added and deleted against this revision's own parent.
890 pub diffstat: Option<(usize, usize)>,
891 /// The base this revision was built on, abbreviated.
892 pub base: Option<String>,
893}
894
895/// Which two revisions the interdiff compares.
896pub struct Compare<'a> {
897 pub a: i32,
898 pub b: i32,
899 /// `None` when A and B are the same revision, which has no interdiff.
900 pub diff: Option<&'a Diff>,
901}
902
903/// The revisions timeline.
904///
905/// The heart of the product. Every rewrite of a change appends a revision here,
906/// and picking any two produces the *interdiff* — what a reviewer has not seen
907/// yet. On a branch-based forge this view cannot exist: a force-push destroys
908/// the thing it would compare against.
909///
910/// A and B are chosen by link, not by script. Two query parameters, two sets of
911/// radio-styled links, and the server does the diff — so this works with
912/// scripting off and every comparison is a URL somebody can paste into a review.
913pub fn revisions(
914 ctx: &RepoContext,
915 c: &ChangeHead<'_>,
916 revs: &[RevisionDetail],
917 cmp: Compare<'_>,
918) -> Markup {
919 let base = format!("{}/changes/{}", ctx.base(), c.number);
920 let pick = |a: i32, b: i32| format!("{base}/revisions?a={a}&b={b}");
921
922 html! {
923 div .band-head {
924 h2 style="margin:0" { "Every version this change has been" }
925 span .band-note {
926 "Pick any two revisions; the interdiff is what a reviewer has not seen yet."
927 }
928 }
929
930 div .revtimeline {
931 @for (i, r) in revs.iter().enumerate().rev() {
932 @let selected = r.seq == cmp.a || r.seq == cmp.b;
933 @let colour = if r.conflicted { "var(--conflict)" } else { "var(--identity)" };
934 div .revrow .is-selected[selected] {
935 // The rail is drawn from two half-segments so the first and
936 // last rows have no line dangling past the end of the list.
937 span .revrail aria-hidden="true" {
938 span .revrail-seg
939 style=(format!("background:{}",
940 if i == revs.len() - 1 { "transparent" } else { "var(--identity)" })) {}
941 span .revdot
942 style=(format!("border-color:{colour};background:{}",
943 if selected { colour } else { "var(--bg)" })) {}
944 span .revrail-seg
945 style=(format!("background:{}",
946 if i == 0 { "transparent" } else { "var(--identity)" })) {}
947 }
948
949 div .revbody {
950 div .revline {
951 span .revlabel style=(format!("color:{colour}")) { "rev " (r.seq) }
952 span .revnote { (first_line(&r.message)) }
953 @if r.conflicted {
954 span .badge.badge-conflict {
955 span .glyph aria-hidden="true" { "◆" }
956 "conflicted"
957 }
958 }
959 span .spacer {}
960 span .revwhen
961 title=(r.pushed_at.format("%Y-%m-%d %H:%M UTC").to_string()) {
962 (r.pushed_at.format("%b %-d %H:%M").to_string())
963 }
964 }
965 div .revmeta {
966 a href=(format!("{}/tree/{}/", ctx.base(), r.rev)) {
967 "commit " (df_store::abbreviate_rev(&r.rev))
968 }
969 @if let Some(b) = &r.base {
970 span { "base " (b) }
971 }
972 @if let Some((add, del)) = r.diffstat {
973 span {
974 span .cl-add { "+" (add) }
975 " "
976 span .cl-del { "−" (del) }
977 }
978 }
979 @if let Some(p) = &r.pushed_by {
980 span { "pushed by " (crate::views::user_link(p)) }
981 } @else {
982 span { "authored by " (r.author_name) }
983 }
984 span .spacer {}
985 a .abpick .is-on[cmp.a == r.seq] href=(pick(r.seq, cmp.b))
986 title=(format!("Compare from revision {}", r.seq)) { "A" }
987 a .abpick .is-on[cmp.b == r.seq] href=(pick(cmp.a, r.seq))
988 title=(format!("Compare to revision {}", r.seq)) { "B" }
989 }
990 }
991 }
992 }
993 }
994
995 div .filediff.interdiff {
996 div .filediff-head {
997 span .label-condensed {
998 "Interdiff rev " (cmp.a) " → rev " (cmp.b)
999 }
1000 span .band-note { "what the reviewer has not seen yet" }
1001 span .spacer {}
1002 @if let Some(d) = cmp.diff {
1003 span .mono.faint {
1004 (d.files.len())
1005 @if d.files.len() == 1 { " file · " } @else { " files · " }
1006 span .cl-add { "+" (d.total_additions) }
1007 " "
1008 span .cl-del { "−" (d.total_deletions) }
1009 }
1010 }
1011 }
1012
1013 @match cmp.diff {
1014 None => {
1015 p .hint style="padding:12px" {
1016 "A and B are the same revision. Pick two different ones to see \
1017 what changed between them."
1018 }
1019 }
1020 Some(d) if d.files.is_empty() => {
1021 p .hint style="padding:12px" {
1022 "Nothing changed between these two revisions. A rebase that only \
1023 moved the change produces exactly this — which is the point."
1024 }
1025 }
1026 Some(d) => {
1027 @for f in &d.files {
1028 div .interdiff-file {
1029 div .interdiff-path {
1030 span .mono { (f.path) }
1031 span .mono.faint {
1032 span .cl-add { "+" (f.additions) }
1033 " "
1034 span .cl-del { "−" (f.deletions) }
1035 }
1036 }
1037 @for h in &f.hunks {
1038 div .diffline.diff-hunk {
1039 span .diff-ln {}
1040 span .diff-text {
1041 "@@ -" (h.old_start) "," (h.old_lines)
1042 " +" (h.new_start) "," (h.new_lines) " @@"
1043 }
1044 }
1045 @for l in &h.lines {
1046 div .diffline.(line_class(l.kind)) {
1047 span .diff-ln {
1048 @if let Some(n) = l.new_lineno.or(l.old_lineno) { (n) }
1049 }
1050 span .diff-text {
1051 // Fixed-width, so a context line's
1052 // absent sign still holds its column.
1053 span .diff-sign { (marker(l.kind)) }
1054 (spans(&l.spans, l.kind))
1055 }
1056 }
1057 }
1058 }
1059 }
1060 }
1061 @if d.truncated {
1062 p .hint style="padding:12px" {
1063 "This interdiff is too large to render in full."
1064 }
1065 }
1066 }
1067 }
1068 }
1069 }
1070}
1071
1072// ─── checks ──────────────────────────────────────────────────────────────────
1073
1074/// The Checks tab.
1075///
1076/// Dogfood has no CI integration: there is no checks table, no worker that
1077/// records results, and nothing that receives them from outside. The tab exists
1078/// because the design places it in the strip, and it says so plainly rather
1079/// than showing invented rows — a fabricated "cargo test ✓ 412 passed" on a
1080/// review page is the single most dangerous kind of placeholder, because a
1081/// reviewer would act on it.
1082pub fn checks(_ctx: &RepoContext, _c: &ChangeHead<'_>) -> Markup {
1083 html! {
1084 div .empty {
1085 h2 { "No checks are wired up" }
1086 p .measure {
1087 "This instance has no CI integration, so nothing reports check results \
1088 against a revision. Nothing is hidden here — there is genuinely no \
1089 data behind this tab yet."
1090 }
1091 p .hint.measure {
1092 "When there is, checks will run per revision rather than per branch: a \
1093 conflicted revision still runs, because a conflict is a state, not a \
1094 failure."
1095 }
1096 }
1097 }
1098}
1099
1100// ─── conflicts (M4) ──────────────────────────────────────────────────────────
1101
1102pub fn conflicts(_ctx: &RepoContext, _c: &ChangeHead<'_>, files: &[ConflictedFile]) -> Markup {
1103 html! {
1104 div .panel {
1105 h2 { "Conflicts" }
1106 p .dim {
1107 "Dogfood shows conflicts read-only. Resolve them in your working copy \
1108 with " code { "jj resolve" } " and push again — the change, this review, \
1109 and every comment on it stay where they are."
1110 }
1111
1112 @if files.is_empty() {
1113 p .hint {
1114 "The head revision is marked conflicted but no conflicted file could \
1115 be read. This usually means the conflict is structural — a delete \
1116 against a modify at the directory level."
1117 }
1118 }
1119
1120 @for f in files {
1121 div .filediff {
1122 div .row .filediff-head { span .mono { (f.path) } }
1123 div .conflict-columns {
1124 @for (label, content) in &f.sides {
1125 div .conflict-side .conflict-base[label.is_base()] {
1126 div .label-condensed { (label.label()) }
1127 @match content {
1128 // A column that does not contain the file at
1129 // all is a delete/modify conflict, and saying
1130 // so is the whole point of showing it.
1131 None => p .hint { "not present in this side" },
1132 Some(text) => pre { code { (text) } },
1133 }
1134 }
1135 }
1136 }
1137 }
1138 }
1139 }
1140 }
1141}
1142
1143// ─── stack graph (M4) ────────────────────────────────────────────────────────
1144
1145pub struct StackNode {
1146 pub number: i64,
1147 pub change_id: String,
1148 pub synthetic: bool,
1149 pub title: String,
1150 pub state: String,
1151 pub conflicted: bool,
1152 pub is_current: bool,
1153 /// Depth from the bottom of the stack, for indentation.
1154 pub depth: usize,
1155}
1156
1157/// The stack page.
1158///
1159/// One rebase moves every change in the chain, and every id survives it — so
1160/// every review, approval and permalink in the stack stays attached to the work
1161/// it was about. That sentence is the page; the rows are the evidence.
1162pub fn stack(
1163 ctx: &RepoContext,
1164 nodes: &[StackNode],
1165 change_id: &str,
1166 target: Option<&str>,
1167 csrf: &str,
1168 can_merge: bool,
1169) -> Markup {
1170 let base = ctx.base();
1171
1172 // Top of the stack first — that is how `jj log` reads, and the change a
1173 // reviewer is looking at is usually near the top.
1174 let chain: String = nodes
1175 .iter()
1176 .rev()
1177 .map(|n| n.change_id[..4.min(n.change_id.len())].to_string())
1178 .collect::<Vec<_>>()
1179 .join(" → ");
1180
1181 html! {
1182 div .page-head {
1183 h1 { "Stack" }
1184 @if nodes.len() > 1 {
1185 span .stack-chain.mono {
1186 (chain)
1187 @if let Some(t) = target { " onto " (t) }
1188 }
1189 }
1190 span .spacer {}
1191 @if nodes.len() > 1 && can_merge {
1192 form method="post" action=(format!("{base}/stacks/{change_id}/merge")) {
1193 input type="hidden" name="_csrf" value=(csrf);
1194 button .btn.btn-primary type="submit" {
1195 "Merge stack into " (target.unwrap_or("the bookmark"))
1196 }
1197 }
1198 }
1199 }
1200
1201 @if nodes.len() <= 1 {
1202 div .empty {
1203 h2 { "Not stacked" }
1204 p { "This change does not sit in a stack." }
1205 p { a .btn href=(format!("{base}/changes/{change_id}")) { "Back to the change" } }
1206 }
1207 } @else {
1208 p .dim.measure {
1209 "One rebase moves all " (nodes.len()) ". Every id survives it, so every \
1210 review, approval, and permalink in the stack stays attached to the work \
1211 it was about."
1212 }
1213
1214 div .filelist {
1215 @for n in nodes.iter().rev() {
1216 @let (glyph, colour) = match (n.conflicted, n.state.as_str()) {
1217 (true, _) => ("◆", "var(--conflict)"),
1218 (_, "merged") => ("⤳", "var(--merged)"),
1219 (_, "abandoned") => ("×", "var(--abandoned)"),
1220 _ => ("○", "var(--open)"),
1221 };
1222 a .stackrow .is-current[n.is_current]
1223 href=(format!("{base}/changes/{}", n.number)) {
1224 span .cl-indent style=(format!("width:{}px", n.depth * 10 + 8)) {}
1225 span .cl-rail aria-hidden="true" {}
1226 span .stackrow-glyph aria-hidden="true" style=(format!("color:{colour}")) {
1227 (glyph)
1228 }
1229 (change_chip(&n.change_id, n.synthetic))
1230 span .stackrow-title { (n.title) }
1231 span .spacer {}
1232 @if n.is_current {
1233 span .chip { "you are here" }
1234 }
1235 span .stackrow-meta { "#" (n.number) }
1236 }
1237 }
1238 // The base the whole chain sits on. `┴` is the same glyph
1239 // `jj log` closes a graph with.
1240 div .stackrow.stackrow-base {
1241 span .stackrow-glyph aria-hidden="true" { "┴" }
1242 @if let Some(t) = target { (t) } @else { "the target bookmark" }
1243 }
1244 }
1245
1246 div .stack-cmd.mono {
1247 "$ jj rebase -s " (&change_id[..4.min(change_id.len())])
1248 @if let Some(t) = target { " -d " (t) }
1249 }
1250
1251 p .hint.measure {
1252 "Merging the bottom of a stack lands only that change. "
1253 strong { "Merge stack" } " lands the whole chain bottom-up in one action."
1254 }
1255 }
1256 }
1257}
1258
1259// ─── helpers ─────────────────────────────────────────────────────────────────
1260
1261fn first_line(s: &str) -> &str {
1262 s.lines().next().unwrap_or("").trim()
1263}
1264
1265#[cfg(test)]
1266mod tests {
1267 use super::*;
1268
1269 fn ev(kind: &str, payload: serde_json::Value) -> EventRow {
1270 EventRow {
1271 kind: kind.into(),
1272 actor: Some("alice".into()),
1273 created_at: Utc::now(),
1274 payload,
1275 }
1276 }
1277
1278 #[test]
1279 fn known_events_read_as_sentences() {
1280 assert_eq!(
1281 event_text(&ev("change.merged", serde_json::json!({"bookmark": "main"}))),
1282 "alice merged this into main"
1283 );
1284 assert_eq!(
1285 event_text(&ev("change.opened", serde_json::json!({}))),
1286 "alice opened this change"
1287 );
1288 }
1289
1290 /// An event the UI does not know about still happened. Dropping it would
1291 /// make the timeline quietly incomplete, which is worse than an ugly line.
1292 #[test]
1293 fn unknown_events_are_shown_rather_than_hidden() {
1294 let text = event_text(&ev("something.new", serde_json::json!({})));
1295 assert!(text.contains("something.new"), "{text}");
1296 }
1297
1298 #[test]
1299 fn an_actorless_event_still_renders() {
1300 let mut e = ev("change.opened", serde_json::json!({}));
1301 e.actor = None;
1302 assert_eq!(event_text(&e), "someone opened this change");
1303 }
1304
1305 #[test]
1306 fn revision_abbreviation_goes_through_the_store() {
1307 // Spec §3 rule 2: RevId is opaque and abbreviation lives in df-store.
1308 let text = event_text(&ev(
1309 "change.pushed",
1310 serde_json::json!({"rev": "0123456789abcdef0123456789abcdef01234567"}),
1311 ));
1312 assert!(text.ends_with("0123456789ab"), "{text}");
1313 }
1314
1315 use crate::views::diff::tests::fixture as diff_fixture;
1316
1317 fn head() -> ChangeHead<'static> {
1318 ChangeHead {
1319 number: 3,
1320 change_id: "kksontuqryot",
1321 synthetic: false,
1322 title: "t",
1323 state: "open",
1324 conflicted: false,
1325 target_bookmark: "main",
1326 author: None,
1327 author_name: None,
1328 revision_count: 1,
1329 head_commit: None,
1330 created_at: Utc::now(),
1331 updated_at: Utc::now(),
1332 file_count: Some(1),
1333 comment_count: 0,
1334 can_manage: false,
1335 can_comment: true,
1336 csrf: "tok",
1337 }
1338 }
1339
1340 /// The resting diff must be code and nothing else. Every commentable line
1341 /// carries a form, but a form that is laid out costs a row per line and is
1342 /// what made a large change unreadable — so the markup is there and the
1343 /// `:target` rule is what reveals exactly one.
1344 #[test]
1345 fn a_comment_form_exists_per_line_but_none_is_open_by_default() {
1346 let d = diff_fixture(30);
1347 let html = diff_with_comments(&d, &[], &head(), "/o/r/changes/3", "abc", false).into_string();
1348
1349 assert_eq!(html.matches("class=\"inline-form\"").count(), 30);
1350 // Nothing renders the old always-visible summary chrome any more.
1351 assert!(!html.contains("Comment on line 1<"), "per-line chrome is back");
1352 // The affordance and the form agree on the fragment.
1353 let anchor = path_anchor("crates/df-web/src/views/review.rs");
1354 assert!(html.contains(&format!("href=\"#c-{anchor}-7\"")));
1355 assert!(html.contains(&format!("id=\"c-{anchor}-7\"")));
1356 }
1357
1358 /// A reader with no comment rights gets the diff and nothing else — no
1359 /// dead affordance, and none of the per-line form markup either.
1360 #[test]
1361 fn a_reader_who_cannot_comment_gets_no_forms() {
1362 let d = diff_fixture(9);
1363 let head = ChangeHead { can_comment: false, ..head() };
1364 let html = diff_with_comments(&d, &[], &head, "/b", "abc", false).into_string();
1365
1366 assert!(!html.contains("inline-form"));
1367 assert!(!html.contains("dl-add"));
1368 // The diff itself is still there.
1369 assert!(html.contains("difftable"));
1370 }
1371}

1371 lines · Rust