Jump to…
snowfeat: given a new redesign and emulated terminal homepagerxkspqmsoknz1mo
Matt W1//! Change list and change detail (M3).
Matt W2//!
Matt W3//! The change list is the hottest page in the product (spec §4), so it reads
Matt W4//! precomputed `change_edges` rather than walking the commit graph.
Matt W5
Matt W6use chrono::{DateTime, Utc};
Matt W7use maud::{html, Markup};
Matt W8
Matt W9
Matt W10use crate::repo_ctx::RepoContext;
Matt W11
Matt W12pub struct ChangeRow {
Matt W13 pub number: i64,
Matt W14 pub change_id: String,
Matt W15 pub synthetic: bool,
Matt W16 pub title: String,
Matt W17 pub state: String,
Matt W18 pub conflicted: bool,
Matt W19 pub updated_at: DateTime<Utc>,
Matt W20 pub author: Option<String>,
Matt W21 /// The name the commit itself carries, used when no account matched.
Matt W22 pub author_name: Option<String>,
Matt W23 /// The head revision's id, used to fetch the row's diffstat.
Matt W24 pub head_rev: Option<String>,
Matt W25 pub revision_count: i64,
Matt W26 /// The changes this one is stacked on.
Matt W27 pub parents: Vec<String>,
Matt W28 pub comments: i64,
Matt W29 /// Verdicts given on this change, most recent per reviewer.
Matt W30 pub reviewers: Vec<Reviewer>,
Matt W31 /// Added/deleted lines in the head revision. `None` when the store could
Matt W32 /// not produce a diff for it.
Matt W33 pub diffstat: Option<(usize, usize)>,
Matt W34 /// Depth within its stack, and how many changes that stack has. Filled in
Matt W35 /// by [`arrange`]; `stack_size` of 1 means "not in a stack".
Matt W36 pub depth: usize,
Matt W37 pub stack_size: usize,
Matt W38}
Matt W39
Matt W40/// One reviewer's standing verdict on a change.
Matt W41pub struct Reviewer {
Matt W42 pub handle: String,
Matt W43 pub verdict: String,
Matt W44 /// Whether the verdict was given on the change's *current* head revision.
Matt W45 /// A stale approval is not an approval, and the list has to show the
Matt W46 /// difference — that is the whole point of stable change ids.
Matt W47 pub at_head: bool,
Matt W48}
Matt W49
Matt W50impl Reviewer {
Matt W51 /// Ring colour, fill colour and the tooltip a reader needs to decode them.
Matt W52 fn marks(&self) -> (&'static str, &'static str, String) {
Matt W53 match (self.verdict.as_str(), self.at_head) {
Matt W54 ("approved", true) => ("var(--open)", "var(--open)", format!("{} · approved", self.handle)),
Matt W55 ("approved", false) => (
Matt W56 "var(--conflict)",
Matt W57 "var(--text-dim)",
Matt W58 format!("{} · approved an earlier revision", self.handle),
Matt W59 ),
Matt W60 ("rejected", _) => (
Matt W61 "var(--danger)",
Matt W62 "var(--danger)",
Matt W63 format!("{} · requested changes", self.handle),
Matt W64 ),
Matt W65 _ => (
Matt W66 "var(--border-strong)",
Matt W67 "var(--text-dim)",
Matt W68 format!("{} · commented", self.handle),
Matt W69 ),
Matt W70 }
Matt W71 }
Matt W72}
Matt W73
Matt W74/// The change id — the product's central concept made visible.
Matt W75///
Matt W76/// Rendered inline rather than boxed: this appears on nearly every row of every
Matt W77/// listing, and a chip around each one turns a dense table into a field of
Matt W78/// pills. The shortest-prefix half carries `--identity`, the remainder fades —
Matt W79/// the emphasis is on the part you actually type.
Matt W80///
Matt W81/// A synthetic id gets no identity colour at all (spec §4: "the UI shows them
Matt W82/// without a change chip and with reduced revision-history guarantees"), because
Matt W83/// presenting a synthesised id as a change id would be a lie the reader cannot
Matt W84/// detect.
Matt W85pub fn change_chip(change_id: &str, synthetic: bool) -> Markup {
Matt W86 html! {
Matt W87 @if synthetic {
Matt W88 span .chip title="Authored with plain git — identity derived from the patch" {
Matt W89 "git"
Matt W90 }
Matt W91 } @else {
Matt W92 span .cid title=(format!("jj change id: {change_id}")) {
Matt W93 (crate::views::repo::cid_parts(change_id))
Matt W94 }
Matt W95 }
Matt W96 }
Matt W97}
Matt W98
Matt W99/// The state pill.
Matt W100///
Matt W101/// Outlined, with a glyph. `conflicted` is not a state of its own — it is a
Matt W102/// thing an *open* change can be — so a conflicted change renders one pill that
Matt W103/// says so, rather than two pills that have to be read together.
Matt W104pub fn state_badge(state: &str, conflicted: bool) -> Markup {
Matt W105 let (class, glyph, label) = match (conflicted, state) {
Matt W106 (true, _) => ("badge-conflict", "◆", "conflicted"),
Matt W107 (_, "merged") => ("badge-merged", "⤳", "merged"),
Matt W108 (_, "abandoned") => ("badge-abandoned", "×", "abandoned"),
Matt W109 (_, "draft") => ("badge-draft", "·", "draft"),
Matt W110 _ => ("badge-open", "○", "open"),
Matt W111 };
Matt W112
Matt W113 html! {
Matt W114 span .badge.(class) {
Matt W115 span .glyph aria-hidden="true" { (glyph) }
Matt W116 (label)
Matt W117 }
Matt W118 }
Matt W119}
Matt W120
Matt W121/// Reorder a page of changes so stacks appear as stacks.
Matt W122///
Matt W123/// Changes come out of the database newest-first, which scatters the members of
Matt W124/// a stack through the list. A stack is the thing branches cannot represent, so
Matt W125/// the list has to show one: this walks the parent/child edges *within the
Matt W126/// loaded page*, assigns each row a depth, and re-emits the page with each
Matt W127/// stack contiguous and deepest-first — the same order `jj log` uses, tip at
Matt W128/// the top.
Matt W129///
Matt W130/// Edges pointing outside the page are ignored rather than followed. The page
Matt W131/// is a filtered view (open only, or a revset), and silently pulling in a
Matt W132/// merged parent to complete a stack would mean the list showed rows the
Matt W133/// filter excluded.
Matt W134///
Matt W135/// Rows keep their relative recency: a stack takes the list position of its
Matt W136/// most recently updated member.
Matt W137pub fn arrange(mut rows: Vec<ChangeRow>) -> Vec<ChangeRow> {
Matt W138 use std::collections::{HashMap, HashSet};
Matt W139
Matt W140 let present: HashSet<&str> = rows.iter().map(|r| r.change_id.as_str()).collect();
Matt W141
Matt W142 // Depth = how many ancestors this row has inside the page. Bounded by the
Matt W143 // page size, so a cycle (which the indexer should never produce, but a
Matt W144 // corrupted edge table could) terminates instead of hanging.
Matt W145 let parents: HashMap<String, Vec<String>> = rows
Matt W146 .iter()
Matt W147 .map(|r| {
Matt W148 let ps = r
Matt W149 .parents
Matt W150 .iter()
Matt W151 .filter(|p| present.contains(p.as_str()))
Matt W152 .cloned()
Matt W153 .collect();
Matt W154 (r.change_id.clone(), ps)
Matt W155 })
Matt W156 .collect();
Matt W157
Matt W158 let limit = rows.len();
Matt W159 let depth_of = |start: &str| -> usize {
Matt W160 let mut depth = 0;
Matt W161 let mut cur = start.to_string();
Matt W162 let mut seen = HashSet::new();
Matt W163 while seen.insert(cur.clone()) && depth < limit {
Matt W164 match parents.get(&cur).and_then(|ps| ps.first()) {
Matt W165 Some(p) => {
Matt W166 depth += 1;
Matt W167 cur = p.clone();
Matt W168 }
Matt W169 None => break,
Matt W170 }
Matt W171 }
Matt W172 depth
Matt W173 };
Matt W174
Matt W175 // Group id = the bottom of the stack, found by walking down to a row with
Matt W176 // no parent in the page.
Matt W177 let root_of = |start: &str| -> String {
Matt W178 let mut cur = start.to_string();
Matt W179 let mut seen = HashSet::new();
Matt W180 while seen.insert(cur.clone()) {
Matt W181 match parents.get(&cur).and_then(|ps| ps.first()) {
Matt W182 Some(p) => cur = p.clone(),
Matt W183 None => break,
Matt W184 }
Matt W185 }
Matt W186 cur
Matt W187 };
Matt W188
Matt W189 let mut roots: HashMap<String, String> = HashMap::new();
Matt W190 for r in &mut rows {
Matt W191 r.depth = depth_of(&r.change_id);
Matt W192 roots.insert(r.change_id.clone(), root_of(&r.change_id));
Matt W193 }
Matt W194
Matt W195 let mut sizes: HashMap<&str, usize> = HashMap::new();
Matt W196 for root in roots.values() {
Matt W197 *sizes.entry(root.as_str()).or_insert(0) += 1;
Matt W198 }
Matt W199 for r in &mut rows {
Matt W200 r.stack_size = sizes[roots[&r.change_id].as_str()];
Matt W201 }
Matt W202
Matt W203 // A stack inherits the list position of its freshest member, so re-grouping
Matt W204 // never pushes active work below stale work.
Matt W205 let mut order: Vec<&str> = Vec::new();
Matt W206 let mut seen: HashSet<&str> = HashSet::new();
Matt W207 for r in &rows {
Matt W208 let root = roots[&r.change_id].as_str();
Matt W209 if seen.insert(root) {
Matt W210 order.push(root);
Matt W211 }
Matt W212 }
Matt W213 let rank: HashMap<&str, usize> = order.iter().enumerate().map(|(i, r)| (*r, i)).collect();
Matt W214
Matt W215 rows.sort_by_key(|r| {
Matt W216 let root = roots[&r.change_id].as_str();
Matt W217 // Deepest first within a stack: the tip is what you are working on.
Matt W218 (rank[root], usize::MAX - r.depth)
Matt W219 });
Matt W220 rows
Matt W221}
Matt W222
Matt W223#[cfg(test)]
Matt W224mod arrange_tests {
Matt W225 use super::{arrange, ChangeRow};
Matt W226 use chrono::Utc;
Matt W227
Matt W228 fn row(id: &str, parents: &[&str]) -> ChangeRow {
Matt W229 ChangeRow {
Matt W230 number: 1,
Matt W231 change_id: id.into(),
Matt W232 synthetic: false,
Matt W233 title: id.into(),
Matt W234 state: "open".into(),
Matt W235 conflicted: false,
Matt W236 updated_at: Utc::now(),
Matt W237 author: None,
Matt W238 author_name: None,
Matt W239 head_rev: None,
Matt W240 revision_count: 1,
Matt W241 parents: parents.iter().map(|s| (*s).to_string()).collect(),
Matt W242 comments: 0,
Matt W243 reviewers: vec![],
Matt W244 diffstat: None,
Matt W245 depth: 0,
Matt W246 stack_size: 0,
Matt W247 }
Matt W248 }
Matt W249
Matt W250 #[test]
Matt W251 fn a_stack_comes_out_contiguous_and_tip_first() {
Matt W252 // Loaded newest-first and interleaved with an unrelated change.
Matt W253 let out = arrange(vec![
Matt W254 row("solo", &[]),
Matt W255 row("mid", &["bottom"]),
Matt W256 row("top", &["mid"]),
Matt W257 row("bottom", &[]),
Matt W258 ]);
Matt W259
Matt W260 let ids: Vec<&str> = out.iter().map(|r| r.change_id.as_str()).collect();
Matt W261 assert_eq!(ids, ["solo", "top", "mid", "bottom"]);
Matt W262 assert_eq!(out[1].depth, 2);
Matt W263 assert_eq!(out[3].depth, 0);
Matt W264 assert!(out[1..].iter().all(|r| r.stack_size == 3));
Matt W265 assert_eq!(out[0].stack_size, 1);
Matt W266 }
Matt W267
Matt W268 /// An edge to a change the filter excluded must not change the grouping.
Matt W269 #[test]
Matt W270 fn edges_leaving_the_page_are_ignored() {
Matt W271 let out = arrange(vec![row("child", &["merged-parent-not-loaded"])]);
Matt W272 assert_eq!(out[0].depth, 0);
Matt W273 assert_eq!(out[0].stack_size, 1);
Matt W274 }
Matt W275
Matt W276 /// A corrupted edge table must not hang the change list.
Matt W277 #[test]
Matt W278 fn a_cycle_terminates() {
Matt W279 let out = arrange(vec![row("a", &["b"]), row("b", &["a"])]);
Matt W280 assert_eq!(out.len(), 2);
Matt W281 }
Matt W282}
Matt W283
Matt W284pub struct ListFilters<'a> {
Matt W285 pub state: &'a str,
Matt W286 pub revset: &'a str,
Matt W287 pub revset_error: Option<&'a str>,
Matt W288 /// Counts for the filter tabs, in tab order.
Matt W289 pub counts: ListCounts,
Matt W290 /// Whether the viewer has an account, which decides if "Mine" is offered.
Matt W291 pub signed_in: bool,
Matt W292 pub week: WeekStats,
Matt W293}
Matt W294
Matt W295/// Row counts behind the filter tabs.
Matt W296#[derive(Debug, Clone, Copy, Default, sqlx::FromRow)]
Matt W297pub struct ListCounts {
Matt W298 pub open: i64,
Matt W299 pub conflicted: i64,
Matt W300 pub merged: i64,
Matt W301 pub abandoned: i64,
Matt W302 pub mine: i64,
Matt W303}
Matt W304
Matt W305/// The aside's "this week" block.
Matt W306#[derive(Debug, Clone, Copy, Default, sqlx::FromRow)]
Matt W307pub struct WeekStats {
Matt W308 pub merged: i64,
Matt W309 pub opened: i64,
Matt W310 pub resolved: i64,
Matt W311 /// Median minutes from a change opening to its first review. `None` when
Matt W312 /// nothing was reviewed this week — there is no median of an empty set,
Matt W313 /// and printing "0m" would claim instant reviews.
Matt W314 pub median_first_review_mins: Option<i64>,
Matt W315}
Matt W316
Matt W317/// The change list — the hottest page in the product.
Matt W318///
Matt W319/// Five columns of fixed-width facts with one elastic column (the title), a
Matt W320/// revset box that filters them, and a stack rail that makes a chain of
Matt W321/// dependent work look like one thing. Everything here is a link or a form:
Matt W322/// there is no state in this page that JavaScript owns.
Matt W323pub fn list(ctx: &RepoContext, rows: &[ChangeRow], f: ListFilters<'_>) -> Markup {
Matt W324 let base = ctx.base();
Matt W325 let now = Utc::now();
Matt W326 let conflicted_here = rows.iter().filter(|r| r.conflicted).count();
Matt W327
Matt W328 // The revset survives a tab click and vice versa, so narrowing by state
Matt W329 // does not silently throw away the expression someone just wrote.
Matt W330 let tab_href = |key: &str| {
Matt W331 if f.revset.is_empty() {
Matt W332 format!("{base}/changes?state={key}")
Matt W333 } else {
Matt W334 format!(
Matt W335 "{base}/changes?state={key}&revset={}",
Matt W336 crate::routes::settings::urlencode(f.revset)
Matt W337 )
Matt W338 }
Matt W339 };
Matt W340
Matt W341 let tabs: Vec<(&str, &str, &str, &str, i64)> = [
Matt W342 ("open", "Open", "○", "var(--open)", f.counts.open),
Matt W343 ("conflicted", "Conflicted", "◆", "var(--conflict)", f.counts.conflicted),
Matt W344 ("merged", "Merged", "⤳", "var(--merged)", f.counts.merged),
Matt W345 ("abandoned", "Abandoned", "×", "var(--abandoned)", f.counts.abandoned),
Matt W346 ("mine", "Mine", "·", "var(--text-faint)", f.counts.mine),
Matt W347 ]
Matt W348 .into_iter()
Matt W349 .filter(|(key, ..)| *key != "mine" || f.signed_in)
Matt W350 .collect();
Matt W351
Matt W352 html! {
Matt W353 div .page-head {
Matt W354 h1 { "Changes" }
Matt W355 span .spacer {}
Matt W356 a .btn.btn-primary href=(format!("{base}/changes/new")) { "New change" }
Matt W357 }
Matt W358
Matt W359 div .columns.columns-repo {
Matt W360 div .columns-main {
Matt W361 form .revset-bar method="get" action=(format!("{base}/changes")) {
Matt W362 label .revset-tag for="revset" { "revset" }
Matt W363 input #revset type="text" name="revset" value=(f.revset)
Matt W364 spellcheck="false" autocapitalize="off" autocomplete="off"
Matt W365 placeholder="open() | conflict()"
Matt W366 aria-label="Filter changes by revset";
Matt W367 input type="hidden" name="state" value=(f.state);
Matt W368 @match f.revset_error {
Matt W369 Some(e) => {
Matt W370 span .revset-status.is-bad role="alert" {
Matt W371 span aria-hidden="true" { "!" } " " (e)
Matt W372 }
Matt W373 }
Matt W374 None => {
Matt W375 span .revset-status {
Matt W376 span aria-hidden="true" { "✓" }
Matt W377 " " (rows.len()) @if rows.len() == 1 { " change" } @else { " changes" }
Matt W378 }
Matt W379 }
Matt W380 }
Matt W381 button .btn.btn-mono type="submit" { "filter" }
Matt W382 }
Matt W383
Matt W384 nav .subtabs.ruled.filter-tabs aria-label="Filter by state" {
Matt W385 @for (key, label, glyph, colour, n) in &tabs {
Matt W386 a href=(tab_href(key)) .active[f.state == *key]
Matt W387 aria-current=[(f.state == *key).then_some("page")] {
Matt W388 span .filter-glyph aria-hidden="true"
Matt W389 style=[(f.state == *key).then(|| format!("color:{colour}"))] {
Matt W390 (glyph)
Matt W391 }
Matt W392 (label)
Matt W393 span .tab-count { (n) }
Matt W394 }
Matt W395 }
Matt W396 }
Matt W397
Matt W398 @if conflicted_here > 0 && f.state != "conflicted" {
Matt W399 p .list-summary {
Matt W400 (conflicted_here)
Matt W401 @if conflicted_here == 1 { " of these is conflicted" }
Matt W402 @else { " of these are conflicted" }
Matt W403 }
Matt W404 }
Matt W405
Matt W406 @if rows.is_empty() {
Matt W407 div .empty {
Matt W408 h2 { "No changes here" }
Matt W409 @if f.revset_error.is_some() {
Matt W410 p { "Fix the expression above, or clear it to see everything." }
Matt W411 } @else {
Matt W412 p { "Push with " code { "jj git push" } " and changes appear here." }
Matt W413 }
Matt W414 }
Matt W415 } @else {
Matt W416 div .changelist {
Matt W417 div .changelist-head aria-hidden="true" {
Matt W418 div { "Change" }
Matt W419 div { "Title" }
Matt W420 div { "Review" }
Matt W421 div .at-end { "Diff" }
Matt W422 div .at-end { "Updated" }
Matt W423 }
Matt W424 @for (i, r) in rows.iter().enumerate() {
Matt W425 // A stack banner opens each group of two or more.
Matt W426 @if r.stack_size > 1 && rows.get(i.wrapping_sub(1))
Matt W427 .is_none_or(|p| p.stack_size != r.stack_size
Matt W428 || p.depth < r.depth) {
Matt W429 div .stack-banner {
Matt W430 span .stack-banner-rail aria-hidden="true" { "▌" }
Matt W431 span .stack-banner-label { "stack of " (r.stack_size) }
Matt W432 span .stack-banner-note {
Matt W433 "Rebase moves all " (r.stack_size)
Matt W434 "; the ids do not change."
Matt W435 }
Matt W436 }
Matt W437 }
Matt W438 (change_list_row(&base, r, now))
Matt W439 }
Matt W440 }
Matt W441 }
Matt W442 }
Matt W443
Matt W444 aside .columns-aside {
Matt W445 div .aside-block {
Matt W446 div .label-condensed { "Saved revsets" }
Matt W447 @for expr in ["mine()", "conflict()", "author(me) & ~merged()"] {
Matt W448 a .saved-revset href=(format!("{base}/changes?state=all&revset={}",
Matt W449 crate::routes::settings::urlencode(expr)))
Matt W450 .is-current[f.revset == expr] {
Matt W451 (expr)
Matt W452 }
Matt W453 }
Matt W454 }
Matt W455
Matt W456 div .aside-block {
Matt W457 div .label-condensed { "This week" }
Matt W458 (week_stats(&f.week))
Matt W459 }
Matt W460 }
Matt W461 }
Matt W462 }
Matt W463}
Matt W464
Matt W465/// The aside's weekly numbers.
Matt W466fn week_stats(w: &WeekStats) -> Markup {
Matt W467 html! {
Matt W468 div .dotline {
Matt W469 span .dotline-key { "Merged" }
Matt W470 span .dotline-val style="color:var(--merged)" { (w.merged) }
Matt W471 }
Matt W472 div .dotline {
Matt W473 span .dotline-key { "Opened" }
Matt W474 span .dotline-val style="color:var(--open)" { (w.opened) }
Matt W475 }
Matt W476 div .dotline {
Matt W477 span .dotline-key { "Conflicts resolved" }
Matt W478 span .dotline-val style="color:var(--conflict)" { (w.resolved) }
Matt W479 }
Matt W480 div .dotline {
Matt W481 span .dotline-key { "Median time to first review" }
Matt W482 span .dotline-val style="color:var(--text-dim)" {
Matt W483 @match w.median_first_review_mins {
Matt W484 Some(m) => (humanise_minutes(m)),
Matt W485 // Nothing reviewed this week. "—" says that; "0m" would
Matt W486 // claim every change was reviewed instantly.
Matt W487 None => "—",
Matt W488 }
Matt W489 }
Matt W490 }
Matt W491 }
Matt W492}
Matt W493
Matt W494/// Minutes as the coarsest unit that still reads as a duration.
Matt W495fn humanise_minutes(mins: i64) -> String {
Matt W496 match mins {
Matt W497 m if m < 60 => format!("{m}m"),
Matt W498 m if m < 60 * 48 => format!("{}h", m / 60),
Matt W499 m => format!("{}d", m / (60 * 24)),
Matt W500 }
Matt W501}
Matt W502
Matt W503/// One row of the change list.
Matt W504fn change_list_row(base: &str, r: &ChangeRow, now: DateTime<Utc>) -> Markup {
Matt W505 let href = format!("{base}/changes/{}", r.number);
Matt W506 let (glyph, colour) = match (r.conflicted, r.state.as_str()) {
Matt W507 (true, _) => ("◆", "var(--conflict)"),
Matt W508 (_, "merged") => ("⤳", "var(--merged)"),
Matt W509 (_, "abandoned") => ("×", "var(--abandoned)"),
Matt W510 (_, "draft") => ("·", "var(--text-faint)"),
Matt W511 _ => ("○", "var(--open)"),
Matt W512 };
Matt W513
Matt W514 html! {
Matt W515 div .changelist-row .in-stack[r.stack_size > 1] {
Matt W516 div .cl-change {
Matt W517 // The rail is drawn at the row's own depth, so a chain of three
Matt W518 // reads as a chain rather than as three unrelated rows.
Matt W519 @if r.stack_size > 1 {
Matt W520 span .cl-indent style=(format!("width:{}px", r.depth * 8)) {}
Matt W521 span .cl-rail aria-hidden="true" {}
Matt W522 }
Matt W523 span .cl-glyph aria-hidden="true" style=(format!("color:{colour}")) { (glyph) }
Matt W524 a .cid href=(href) title=(format!("jj change id: {}", r.change_id)) {
Matt W525 @if r.synthetic {
Matt W526 span .cid-p.cid-synthetic { (&r.change_id[..8.min(r.change_id.len())]) }
Matt W527 } @else {
Matt W528 (crate::views::repo::cid_parts(&r.change_id))
Matt W529 }
Matt W530 }
Matt W531 }
Matt W532
Matt W533 div .cl-title {
Matt W534 a href=(href) { (r.title) }
Matt W535 @if r.conflicted {
Matt W536 span .badge.badge-conflict {
Matt W537 span .glyph aria-hidden="true" { "◆" }
Matt W538 "conflicted"
Matt W539 }
Matt W540 }
Matt W541 span .cl-byline {
Matt W542 (crate::views::person(r.author.as_deref(), r.author_name.as_deref()))
Matt W543 }
Matt W544 // The visible payoff of stable identity: one review, many
Matt W545 // rewrites.
Matt W546 @if r.revision_count > 1 {
Matt W547 span .cl-revs title="revisions of this change" {
Matt W548 (r.revision_count) " revs"
Matt W549 }
Matt W550 }
Matt W551 }
Matt W552
Matt W553 div .cl-review {
Matt W554 @for rv in &r.reviewers {
Matt W555 @let (ring, fill, tip) = rv.marks();
Matt W556 span .cl-avatar title=(tip)
Matt W557 style=(format!("border-color:{ring};color:{fill}")) {
Matt W558 (initials(&rv.handle))
Matt W559 }
Matt W560 }
Matt W561 @if r.comments > 0 {
Matt W562 span .cl-comments title="comments" { (r.comments) "⌾" }
Matt W563 }
Matt W564 }
Matt W565
Matt W566 div .cl-diff {
Matt W567 @match r.diffstat {
Matt W568 Some((add, del)) => {
Matt W569 span .cl-bars title=(format!("+{add} −{del}")) aria-hidden="true" {
Matt W570 @for filled in bars(add, del) {
Matt W571 span style=(format!(
Matt W572 "background:{}",
Matt W573 if filled { "var(--diff-add-text)" } else { "var(--diff-del-text)" }
Matt W574 )) {}
Matt W575 }
Matt W576 }
Matt W577 span .cl-add { "+" (add) }
Matt W578 span .cl-del { "" (del) }
Matt W579 }
Matt W580 None => span .faint { "" },
Matt W581 }
Matt W582 }
Matt W583
Matt W584 div .cl-when title=(r.updated_at.format("%Y-%m-%d %H:%M UTC").to_string()) {
Matt W585 (crate::views::relative_time(r.updated_at, now))
Matt W586 }
Matt W587 }
Matt W588 }
Matt W589}
Matt W590
Matt W591/// Five cells, filled green in proportion to how much of the diff was additions.
Matt W592///
Matt W593/// The same idea as GitHub's diffstat bar. A pure deletion shows five red
Matt W594/// cells, a pure addition five green, and the mix in between is rounded — it is
Matt W595/// a glanceable ratio, not a measurement, which is why the exact numbers sit
Matt W596/// beside it.
Matt W597fn bars(add: usize, del: usize) -> [bool; 5] {
Matt W598 let total = add + del;
Matt W599 if total == 0 {
Matt W600 return [false; 5];
Matt W601 }
Matt W602 let green = ((add as f64 / total as f64) * 5.0).round() as usize;
Matt W603 std::array::from_fn(|i| i < green)
Matt W604}
Matt W605
Matt W606/// Up to two letters from a handle, for the reviewer marks.
Matt W607fn initials(handle: &str) -> String {
Matt W608 handle.chars().take(2).collect()
Matt W609}
Matt W610
Matt W611#[cfg(test)]
Matt W612mod bars_tests {
Matt W613 use super::{bars, humanise_minutes};
Matt W614
Matt W615 #[test]
Matt W616 fn the_ratio_reads_the_way_the_diff_does() {
Matt W617 assert_eq!(bars(100, 0), [true; 5]);
Matt W618 assert_eq!(bars(0, 100), [false; 5]);
Matt W619 assert_eq!(bars(50, 50), [true, true, true, false, false]);
Matt W620 assert_eq!(bars(20, 80), [true, false, false, false, false]);
Matt W621 }
Matt W622
Matt W623 /// An empty diff must not divide by zero.
Matt W624 #[test]
Matt W625 fn an_empty_diff_is_all_empty() {
Matt W626 assert_eq!(bars(0, 0), [false; 5]);
Matt W627 }
Matt W628
Matt W629 #[test]
Matt W630 fn durations_read_as_durations() {
Matt W631 assert_eq!(humanise_minutes(42), "42m");
Matt W632 assert_eq!(humanise_minutes(150), "2h");
Matt W633 assert_eq!(humanise_minutes(60 * 24 * 3), "3d");
Matt W634 }
Matt W635}
Matt W636
Matt W637/// Disambiguation page for an ambiguous change-id prefix (spec §7).
Matt W638pub fn ambiguous(ctx: &RepoContext, prefix: &str, candidates: &[(i64, String, String)]) -> Markup {
Matt W639 let base = ctx.base();
Matt W640 html! {
Matt W641 div .panel {
Matt W642 h1 { "Ambiguous change id" }
Matt W643 p .lede {
Matt W644 "More than one change starts with " code { (prefix) } ". Pick one:"
Matt W645 }
Matt W646 div .stack style="gap:0" {
Matt W647 @for (number, change_id, title) in candidates {
Matt W648 div style="padding:10px 0;border-bottom:1px solid var(--border)" {
Matt W649 a href=(format!("{base}/changes/{number}")) { (title) }
Matt W650 div .row style="margin-top:4px;gap:8px" {
Matt W651 span .chip.chip-change { (&change_id[..16.min(change_id.len())]) }
Matt W652 span .faint { "#" (number) }
Matt W653 }
Matt W654 }
Matt W655 }
Matt W656 }
Matt W657 }
Matt W658 }
Matt W659}
Matt W660
Matt W661#[cfg(test)]
Matt W662mod tests {
Matt W663 use super::*;
Matt W664
Matt W665 #[test]
Matt W666 fn synthetic_changes_render_without_a_change_id() {
Matt W667 // Spec §4: "Do not pretend a synthetic identity is a real change ID."
Matt W668 let synthetic = change_chip("ppwkwxvrwvxxyttp0000000000000000", true).into_string();
Matt W669 assert!(synthetic.contains("git"));
Matt W670 assert!(
Matt W671 !synthetic.contains("cid-p"),
Matt W672 "a synthetic id must not get the identity treatment: {synthetic}"
Matt W673 );
Matt W674
Matt W675 // A real id is split into its shortest-prefix half and the remainder,
Matt W676 // so the two carry different weight — but together they are still the
Matt W677 // twelve characters the product displays.
Matt W678 let real = change_chip("klxqnvpqlnlvtkmuqmtmxktlnvnomvwv", false).into_string();
Matt W679 assert!(real.contains(r#"class="cid-p">klxq<"#), "{real}");
Matt W680 assert!(real.contains(r#"class="cid-r">nvpqlnlv<"#), "{real}");
Matt W681 }
Matt W682
Matt W683 #[test]
Matt W684 fn a_conflicted_change_shows_the_conflict_badge_regardless_of_state() {
Matt W685 let m = state_badge("open", true).into_string();
Matt W686 assert!(m.contains("badge-conflict"));
Matt W687 let m = state_badge("merged", true).into_string();
Matt W688 assert!(m.contains("badge-conflict"), "conflict must show on merged too");
Matt W689 }
Matt W690
Matt W691 #[test]
Matt W692 fn unknown_states_fall_back_to_open() {
Matt W693 let m = state_badge("something-new", false).into_string();
Matt W694 assert!(m.contains("badge-open"));
Matt W695 }
Matt W696}
Matt W697
Matt W698// ─── opening a change (M3) ───────────────────────────────────────────────────
Matt W699
Matt W700/// A change that could be proposed for review.
Matt W701pub struct Proposable {
Matt W702 pub change_id: String,
Matt W703 pub number: i64,
Matt W704 pub title: String,
Matt W705 pub synthetic: bool,
Matt W706 pub revisions: i64,
Matt W707 pub state: String,
Matt W708}
Matt W709
Matt W710/// The "open a change" form.
Matt W711///
Matt W712/// The wording is deliberate: the work already exists in the repository, and
Matt W713/// this form proposes it. A jj forge that claimed to *create* a change here
Matt W714/// would be describing something that already happened at push time.
Matt W715pub fn new_change_form(
Matt W716 ctx: &RepoContext,
Matt W717 csrf: &str,
Matt W718 candidates: &[Proposable],
Matt W719 bookmarks: &[String],
Matt W720 error: Option<&str>,
Matt W721) -> Markup {
Matt W722 let base = ctx.base();
Matt W723 html! {
Matt W724 div .panel {
Matt W725 h1 { "Open a change" }
Matt W726 p .lede {
Matt W727 "Pushed work becomes a change the moment Dogfood sees its change id. \
Matt W728 Opening one gives it a target, a title, and a description so it can be \
Matt W729 reviewed."
Matt W730 }
Matt W731
Matt W732 @if let Some(e) = error { div .banner.banner-error role="alert" { (e) } }
Matt W733
Matt W734 @if candidates.is_empty() {
Matt W735 div .empty {
Matt W736 h2 { "Nothing to open" }
Matt W737 p { "Push with " code { "jj git push" } " and the work appears here." }
Matt W738 }
Matt W739 } @else {
Matt W740 form method="post" action=(format!("{base}/changes")) .stack {
Matt W741 input type="hidden" name="_csrf" value=(csrf);
Matt W742
Matt W743 div .field {
Matt W744 label for="change" { "Change" }
Matt W745 select id="change" name="change" required {
Matt W746 @for c in candidates {
Matt W747 option value=(c.change_id) {
Matt W748 "#" (c.number) " · " (c.title)
Matt W749 @if c.state == "draft" { " (draft)" }
Matt W750 @if c.revisions > 1 { " · " (c.revisions) " revisions" }
Matt W751 @if c.synthetic { " · git" }
Matt W752 }
Matt W753 }
Matt W754 }
Matt W755 }
Matt W756
Matt W757 div .field {
Matt W758 label for="target_bookmark" { "Target bookmark" }
Matt W759 select id="target_bookmark" name="target_bookmark" required {
Matt W760 @for b in bookmarks {
Matt W761 option value=(b) selected[*b == ctx.repo.default_bookmark] { (b) }
Matt W762 }
Matt W763 }
Matt W764 p .hint { "Where this change is measured against, and where it lands." }
Matt W765 }
Matt W766
Matt W767 div .field {
Matt W768 label for="title" { "Title" }
Matt W769 input type="text" id="title" name="title" required maxlength="300"
Matt W770 placeholder="Defaults to the description of the top commit.";
Matt W771 }
Matt W772
Matt W773 div .field {
Matt W774 label for="description" { "Description" }
Matt W775 textarea id="description" name="description" rows="6"
Matt W776 placeholder="What this change does and why. Reviewers read this first." {}
Matt W777 }
Matt W778
Matt W779 button .btn.btn-primary type="submit" { "Open change" }
Matt W780 }
Matt W781 }
Matt W782 }
Matt W783 }
Matt W784}

784 lines · Rust