Jump to…
snowfeat: given a new redesign and emulated terminal homepagerxkspqmsoknz1mo
1//! Change list and change detail (M3).
2//!
3//! The change list is the hottest page in the product (spec §4), so it reads
4//! precomputed `change_edges` rather than walking the commit graph.
5
6use chrono::{DateTime, Utc};
7use maud::{html, Markup};
8
9
10use crate::repo_ctx::RepoContext;
11
12pub struct ChangeRow {
13 pub number: i64,
14 pub change_id: String,
15 pub synthetic: bool,
16 pub title: String,
17 pub state: String,
18 pub conflicted: bool,
19 pub updated_at: DateTime<Utc>,
20 pub author: Option<String>,
21 /// The name the commit itself carries, used when no account matched.
22 pub author_name: Option<String>,
23 /// The head revision's id, used to fetch the row's diffstat.
24 pub head_rev: Option<String>,
25 pub revision_count: i64,
26 /// The changes this one is stacked on.
27 pub parents: Vec<String>,
28 pub comments: i64,
29 /// Verdicts given on this change, most recent per reviewer.
30 pub reviewers: Vec<Reviewer>,
31 /// Added/deleted lines in the head revision. `None` when the store could
32 /// not produce a diff for it.
33 pub diffstat: Option<(usize, usize)>,
34 /// Depth within its stack, and how many changes that stack has. Filled in
35 /// by [`arrange`]; `stack_size` of 1 means "not in a stack".
36 pub depth: usize,
37 pub stack_size: usize,
38}
39
40/// One reviewer's standing verdict on a change.
41pub struct Reviewer {
42 pub handle: String,
43 pub verdict: String,
44 /// Whether the verdict was given on the change's *current* head revision.
45 /// A stale approval is not an approval, and the list has to show the
46 /// difference — that is the whole point of stable change ids.
47 pub at_head: bool,
48}
49
50impl Reviewer {
51 /// Ring colour, fill colour and the tooltip a reader needs to decode them.
52 fn marks(&self) -> (&'static str, &'static str, String) {
53 match (self.verdict.as_str(), self.at_head) {
54 ("approved", true) => ("var(--open)", "var(--open)", format!("{} · approved", self.handle)),
55 ("approved", false) => (
56 "var(--conflict)",
57 "var(--text-dim)",
58 format!("{} · approved an earlier revision", self.handle),
59 ),
60 ("rejected", _) => (
61 "var(--danger)",
62 "var(--danger)",
63 format!("{} · requested changes", self.handle),
64 ),
65 _ => (
66 "var(--border-strong)",
67 "var(--text-dim)",
68 format!("{} · commented", self.handle),
69 ),
70 }
71 }
72}
73
74/// The change id — the product's central concept made visible.
75///
76/// Rendered inline rather than boxed: this appears on nearly every row of every
77/// listing, and a chip around each one turns a dense table into a field of
78/// pills. The shortest-prefix half carries `--identity`, the remainder fades —
79/// the emphasis is on the part you actually type.
80///
81/// A synthetic id gets no identity colour at all (spec §4: "the UI shows them
82/// without a change chip and with reduced revision-history guarantees"), because
83/// presenting a synthesised id as a change id would be a lie the reader cannot
84/// detect.
85pub fn change_chip(change_id: &str, synthetic: bool) -> Markup {
86 html! {
87 @if synthetic {
88 span .chip title="Authored with plain git — identity derived from the patch" {
89 "git"
90 }
91 } @else {
92 span .cid title=(format!("jj change id: {change_id}")) {
93 (crate::views::repo::cid_parts(change_id))
94 }
95 }
96 }
97}
98
99/// The state pill.
100///
101/// Outlined, with a glyph. `conflicted` is not a state of its own — it is a
102/// thing an *open* change can be — so a conflicted change renders one pill that
103/// says so, rather than two pills that have to be read together.
104pub fn state_badge(state: &str, conflicted: bool) -> Markup {
105 let (class, glyph, label) = match (conflicted, state) {
106 (true, _) => ("badge-conflict", "◆", "conflicted"),
107 (_, "merged") => ("badge-merged", "⤳", "merged"),
108 (_, "abandoned") => ("badge-abandoned", "×", "abandoned"),
109 (_, "draft") => ("badge-draft", "·", "draft"),
110 _ => ("badge-open", "○", "open"),
111 };
112
113 html! {
114 span .badge.(class) {
115 span .glyph aria-hidden="true" { (glyph) }
116 (label)
117 }
118 }
119}
120
121/// Reorder a page of changes so stacks appear as stacks.
122///
123/// Changes come out of the database newest-first, which scatters the members of
124/// a stack through the list. A stack is the thing branches cannot represent, so
125/// the list has to show one: this walks the parent/child edges *within the
126/// loaded page*, assigns each row a depth, and re-emits the page with each
127/// stack contiguous and deepest-first — the same order `jj log` uses, tip at
128/// the top.
129///
130/// Edges pointing outside the page are ignored rather than followed. The page
131/// is a filtered view (open only, or a revset), and silently pulling in a
132/// merged parent to complete a stack would mean the list showed rows the
133/// filter excluded.
134///
135/// Rows keep their relative recency: a stack takes the list position of its
136/// most recently updated member.
137pub fn arrange(mut rows: Vec<ChangeRow>) -> Vec<ChangeRow> {
138 use std::collections::{HashMap, HashSet};
139
140 let present: HashSet<&str> = rows.iter().map(|r| r.change_id.as_str()).collect();
141
142 // Depth = how many ancestors this row has inside the page. Bounded by the
143 // page size, so a cycle (which the indexer should never produce, but a
144 // corrupted edge table could) terminates instead of hanging.
145 let parents: HashMap<String, Vec<String>> = rows
146 .iter()
147 .map(|r| {
148 let ps = r
149 .parents
150 .iter()
151 .filter(|p| present.contains(p.as_str()))
152 .cloned()
153 .collect();
154 (r.change_id.clone(), ps)
155 })
156 .collect();
157
158 let limit = rows.len();
159 let depth_of = |start: &str| -> usize {
160 let mut depth = 0;
161 let mut cur = start.to_string();
162 let mut seen = HashSet::new();
163 while seen.insert(cur.clone()) && depth < limit {
164 match parents.get(&cur).and_then(|ps| ps.first()) {
165 Some(p) => {
166 depth += 1;
167 cur = p.clone();
168 }
169 None => break,
170 }
171 }
172 depth
173 };
174
175 // Group id = the bottom of the stack, found by walking down to a row with
176 // no parent in the page.
177 let root_of = |start: &str| -> String {
178 let mut cur = start.to_string();
179 let mut seen = HashSet::new();
180 while seen.insert(cur.clone()) {
181 match parents.get(&cur).and_then(|ps| ps.first()) {
182 Some(p) => cur = p.clone(),
183 None => break,
184 }
185 }
186 cur
187 };
188
189 let mut roots: HashMap<String, String> = HashMap::new();
190 for r in &mut rows {
191 r.depth = depth_of(&r.change_id);
192 roots.insert(r.change_id.clone(), root_of(&r.change_id));
193 }
194
195 let mut sizes: HashMap<&str, usize> = HashMap::new();
196 for root in roots.values() {
197 *sizes.entry(root.as_str()).or_insert(0) += 1;
198 }
199 for r in &mut rows {
200 r.stack_size = sizes[roots[&r.change_id].as_str()];
201 }
202
203 // A stack inherits the list position of its freshest member, so re-grouping
204 // never pushes active work below stale work.
205 let mut order: Vec<&str> = Vec::new();
206 let mut seen: HashSet<&str> = HashSet::new();
207 for r in &rows {
208 let root = roots[&r.change_id].as_str();
209 if seen.insert(root) {
210 order.push(root);
211 }
212 }
213 let rank: HashMap<&str, usize> = order.iter().enumerate().map(|(i, r)| (*r, i)).collect();
214
215 rows.sort_by_key(|r| {
216 let root = roots[&r.change_id].as_str();
217 // Deepest first within a stack: the tip is what you are working on.
218 (rank[root], usize::MAX - r.depth)
219 });
220 rows
221}
222
223#[cfg(test)]
224mod arrange_tests {
225 use super::{arrange, ChangeRow};
226 use chrono::Utc;
227
228 fn row(id: &str, parents: &[&str]) -> ChangeRow {
229 ChangeRow {
230 number: 1,
231 change_id: id.into(),
232 synthetic: false,
233 title: id.into(),
234 state: "open".into(),
235 conflicted: false,
236 updated_at: Utc::now(),
237 author: None,
238 author_name: None,
239 head_rev: None,
240 revision_count: 1,
241 parents: parents.iter().map(|s| (*s).to_string()).collect(),
242 comments: 0,
243 reviewers: vec![],
244 diffstat: None,
245 depth: 0,
246 stack_size: 0,
247 }
248 }
249
250 #[test]
251 fn a_stack_comes_out_contiguous_and_tip_first() {
252 // Loaded newest-first and interleaved with an unrelated change.
253 let out = arrange(vec![
254 row("solo", &[]),
255 row("mid", &["bottom"]),
256 row("top", &["mid"]),
257 row("bottom", &[]),
258 ]);
259
260 let ids: Vec<&str> = out.iter().map(|r| r.change_id.as_str()).collect();
261 assert_eq!(ids, ["solo", "top", "mid", "bottom"]);
262 assert_eq!(out[1].depth, 2);
263 assert_eq!(out[3].depth, 0);
264 assert!(out[1..].iter().all(|r| r.stack_size == 3));
265 assert_eq!(out[0].stack_size, 1);
266 }
267
268 /// An edge to a change the filter excluded must not change the grouping.
269 #[test]
270 fn edges_leaving_the_page_are_ignored() {
271 let out = arrange(vec![row("child", &["merged-parent-not-loaded"])]);
272 assert_eq!(out[0].depth, 0);
273 assert_eq!(out[0].stack_size, 1);
274 }
275
276 /// A corrupted edge table must not hang the change list.
277 #[test]
278 fn a_cycle_terminates() {
279 let out = arrange(vec![row("a", &["b"]), row("b", &["a"])]);
280 assert_eq!(out.len(), 2);
281 }
282}
283
284pub struct ListFilters<'a> {
285 pub state: &'a str,
286 pub revset: &'a str,
287 pub revset_error: Option<&'a str>,
288 /// Counts for the filter tabs, in tab order.
289 pub counts: ListCounts,
290 /// Whether the viewer has an account, which decides if "Mine" is offered.
291 pub signed_in: bool,
292 pub week: WeekStats,
293}
294
295/// Row counts behind the filter tabs.
296#[derive(Debug, Clone, Copy, Default, sqlx::FromRow)]
297pub struct ListCounts {
298 pub open: i64,
299 pub conflicted: i64,
300 pub merged: i64,
301 pub abandoned: i64,
302 pub mine: i64,
303}
304
305/// The aside's "this week" block.
306#[derive(Debug, Clone, Copy, Default, sqlx::FromRow)]
307pub struct WeekStats {
308 pub merged: i64,
309 pub opened: i64,
310 pub resolved: i64,
311 /// Median minutes from a change opening to its first review. `None` when
312 /// nothing was reviewed this week — there is no median of an empty set,
313 /// and printing "0m" would claim instant reviews.
314 pub median_first_review_mins: Option<i64>,
315}
316
317/// The change list — the hottest page in the product.
318///
319/// Five columns of fixed-width facts with one elastic column (the title), a
320/// revset box that filters them, and a stack rail that makes a chain of
321/// dependent work look like one thing. Everything here is a link or a form:
322/// there is no state in this page that JavaScript owns.
323pub fn list(ctx: &RepoContext, rows: &[ChangeRow], f: ListFilters<'_>) -> Markup {
324 let base = ctx.base();
325 let now = Utc::now();
326 let conflicted_here = rows.iter().filter(|r| r.conflicted).count();
327
328 // The revset survives a tab click and vice versa, so narrowing by state
329 // does not silently throw away the expression someone just wrote.
330 let tab_href = |key: &str| {
331 if f.revset.is_empty() {
332 format!("{base}/changes?state={key}")
333 } else {
334 format!(
335 "{base}/changes?state={key}&revset={}",
336 crate::routes::settings::urlencode(f.revset)
337 )
338 }
339 };
340
341 let tabs: Vec<(&str, &str, &str, &str, i64)> = [
342 ("open", "Open", "○", "var(--open)", f.counts.open),
343 ("conflicted", "Conflicted", "◆", "var(--conflict)", f.counts.conflicted),
344 ("merged", "Merged", "⤳", "var(--merged)", f.counts.merged),
345 ("abandoned", "Abandoned", "×", "var(--abandoned)", f.counts.abandoned),
346 ("mine", "Mine", "·", "var(--text-faint)", f.counts.mine),
347 ]
348 .into_iter()
349 .filter(|(key, ..)| *key != "mine" || f.signed_in)
350 .collect();
351
352 html! {
353 div .page-head {
354 h1 { "Changes" }
355 span .spacer {}
356 a .btn.btn-primary href=(format!("{base}/changes/new")) { "New change" }
357 }
358
359 div .columns.columns-repo {
360 div .columns-main {
361 form .revset-bar method="get" action=(format!("{base}/changes")) {
362 label .revset-tag for="revset" { "revset" }
363 input #revset type="text" name="revset" value=(f.revset)
364 spellcheck="false" autocapitalize="off" autocomplete="off"
365 placeholder="open() | conflict()"
366 aria-label="Filter changes by revset";
367 input type="hidden" name="state" value=(f.state);
368 @match f.revset_error {
369 Some(e) => {
370 span .revset-status.is-bad role="alert" {
371 span aria-hidden="true" { "!" } " " (e)
372 }
373 }
374 None => {
375 span .revset-status {
376 span aria-hidden="true" { "✓" }
377 " " (rows.len()) @if rows.len() == 1 { " change" } @else { " changes" }
378 }
379 }
380 }
381 button .btn.btn-mono type="submit" { "filter" }
382 }
383
384 nav .subtabs.ruled.filter-tabs aria-label="Filter by state" {
385 @for (key, label, glyph, colour, n) in &tabs {
386 a href=(tab_href(key)) .active[f.state == *key]
387 aria-current=[(f.state == *key).then_some("page")] {
388 span .filter-glyph aria-hidden="true"
389 style=[(f.state == *key).then(|| format!("color:{colour}"))] {
390 (glyph)
391 }
392 (label)
393 span .tab-count { (n) }
394 }
395 }
396 }
397
398 @if conflicted_here > 0 && f.state != "conflicted" {
399 p .list-summary {
400 (conflicted_here)
401 @if conflicted_here == 1 { " of these is conflicted" }
402 @else { " of these are conflicted" }
403 }
404 }
405
406 @if rows.is_empty() {
407 div .empty {
408 h2 { "No changes here" }
409 @if f.revset_error.is_some() {
410 p { "Fix the expression above, or clear it to see everything." }
411 } @else {
412 p { "Push with " code { "jj git push" } " and changes appear here." }
413 }
414 }
415 } @else {
416 div .changelist {
417 div .changelist-head aria-hidden="true" {
418 div { "Change" }
419 div { "Title" }
420 div { "Review" }
421 div .at-end { "Diff" }
422 div .at-end { "Updated" }
423 }
424 @for (i, r) in rows.iter().enumerate() {
425 // A stack banner opens each group of two or more.
426 @if r.stack_size > 1 && rows.get(i.wrapping_sub(1))
427 .is_none_or(|p| p.stack_size != r.stack_size
428 || p.depth < r.depth) {
429 div .stack-banner {
430 span .stack-banner-rail aria-hidden="true" { "▌" }
431 span .stack-banner-label { "stack of " (r.stack_size) }
432 span .stack-banner-note {
433 "Rebase moves all " (r.stack_size)
434 "; the ids do not change."
435 }
436 }
437 }
438 (change_list_row(&base, r, now))
439 }
440 }
441 }
442 }
443
444 aside .columns-aside {
445 div .aside-block {
446 div .label-condensed { "Saved revsets" }
447 @for expr in ["mine()", "conflict()", "author(me) & ~merged()"] {
448 a .saved-revset href=(format!("{base}/changes?state=all&revset={}",
449 crate::routes::settings::urlencode(expr)))
450 .is-current[f.revset == expr] {
451 (expr)
452 }
453 }
454 }
455
456 div .aside-block {
457 div .label-condensed { "This week" }
458 (week_stats(&f.week))
459 }
460 }
461 }
462 }
463}
464
465/// The aside's weekly numbers.
466fn week_stats(w: &WeekStats) -> Markup {
467 html! {
468 div .dotline {
469 span .dotline-key { "Merged" }
470 span .dotline-val style="color:var(--merged)" { (w.merged) }
471 }
472 div .dotline {
473 span .dotline-key { "Opened" }
474 span .dotline-val style="color:var(--open)" { (w.opened) }
475 }
476 div .dotline {
477 span .dotline-key { "Conflicts resolved" }
478 span .dotline-val style="color:var(--conflict)" { (w.resolved) }
479 }
480 div .dotline {
481 span .dotline-key { "Median time to first review" }
482 span .dotline-val style="color:var(--text-dim)" {
483 @match w.median_first_review_mins {
484 Some(m) => (humanise_minutes(m)),
485 // Nothing reviewed this week. "—" says that; "0m" would
486 // claim every change was reviewed instantly.
487 None => "—",
488 }
489 }
490 }
491 }
492}
493
494/// Minutes as the coarsest unit that still reads as a duration.
495fn humanise_minutes(mins: i64) -> String {
496 match mins {
497 m if m < 60 => format!("{m}m"),
498 m if m < 60 * 48 => format!("{}h", m / 60),
499 m => format!("{}d", m / (60 * 24)),
500 }
501}
502
503/// One row of the change list.
504fn change_list_row(base: &str, r: &ChangeRow, now: DateTime<Utc>) -> Markup {
505 let href = format!("{base}/changes/{}", r.number);
506 let (glyph, colour) = match (r.conflicted, r.state.as_str()) {
507 (true, _) => ("◆", "var(--conflict)"),
508 (_, "merged") => ("⤳", "var(--merged)"),
509 (_, "abandoned") => ("×", "var(--abandoned)"),
510 (_, "draft") => ("·", "var(--text-faint)"),
511 _ => ("○", "var(--open)"),
512 };
513
514 html! {
515 div .changelist-row .in-stack[r.stack_size > 1] {
516 div .cl-change {
517 // The rail is drawn at the row's own depth, so a chain of three
518 // reads as a chain rather than as three unrelated rows.
519 @if r.stack_size > 1 {
520 span .cl-indent style=(format!("width:{}px", r.depth * 8)) {}
521 span .cl-rail aria-hidden="true" {}
522 }
523 span .cl-glyph aria-hidden="true" style=(format!("color:{colour}")) { (glyph) }
524 a .cid href=(href) title=(format!("jj change id: {}", r.change_id)) {
525 @if r.synthetic {
526 span .cid-p.cid-synthetic { (&r.change_id[..8.min(r.change_id.len())]) }
527 } @else {
528 (crate::views::repo::cid_parts(&r.change_id))
529 }
530 }
531 }
532
533 div .cl-title {
534 a href=(href) { (r.title) }
535 @if r.conflicted {
536 span .badge.badge-conflict {
537 span .glyph aria-hidden="true" { "◆" }
538 "conflicted"
539 }
540 }
541 span .cl-byline {
542 (crate::views::person(r.author.as_deref(), r.author_name.as_deref()))
543 }
544 // The visible payoff of stable identity: one review, many
545 // rewrites.
546 @if r.revision_count > 1 {
547 span .cl-revs title="revisions of this change" {
548 (r.revision_count) " revs"
549 }
550 }
551 }
552
553 div .cl-review {
554 @for rv in &r.reviewers {
555 @let (ring, fill, tip) = rv.marks();
556 span .cl-avatar title=(tip)
557 style=(format!("border-color:{ring};color:{fill}")) {
558 (initials(&rv.handle))
559 }
560 }
561 @if r.comments > 0 {
562 span .cl-comments title="comments" { (r.comments) "⌾" }
563 }
564 }
565
566 div .cl-diff {
567 @match r.diffstat {
568 Some((add, del)) => {
569 span .cl-bars title=(format!("+{add} −{del}")) aria-hidden="true" {
570 @for filled in bars(add, del) {
571 span style=(format!(
572 "background:{}",
573 if filled { "var(--diff-add-text)" } else { "var(--diff-del-text)" }
574 )) {}
575 }
576 }
577 span .cl-add { "+" (add) }
578 span .cl-del { "" (del) }
579 }
580 None => span .faint { "" },
581 }
582 }
583
584 div .cl-when title=(r.updated_at.format("%Y-%m-%d %H:%M UTC").to_string()) {
585 (crate::views::relative_time(r.updated_at, now))
586 }
587 }
588 }
589}
590
591/// Five cells, filled green in proportion to how much of the diff was additions.
592///
593/// The same idea as GitHub's diffstat bar. A pure deletion shows five red
594/// cells, a pure addition five green, and the mix in between is rounded — it is
595/// a glanceable ratio, not a measurement, which is why the exact numbers sit
596/// beside it.
597fn bars(add: usize, del: usize) -> [bool; 5] {
598 let total = add + del;
599 if total == 0 {
600 return [false; 5];
601 }
602 let green = ((add as f64 / total as f64) * 5.0).round() as usize;
603 std::array::from_fn(|i| i < green)
604}
605
606/// Up to two letters from a handle, for the reviewer marks.
607fn initials(handle: &str) -> String {
608 handle.chars().take(2).collect()
609}
610
611#[cfg(test)]
612mod bars_tests {
613 use super::{bars, humanise_minutes};
614
615 #[test]
616 fn the_ratio_reads_the_way_the_diff_does() {
617 assert_eq!(bars(100, 0), [true; 5]);
618 assert_eq!(bars(0, 100), [false; 5]);
619 assert_eq!(bars(50, 50), [true, true, true, false, false]);
620 assert_eq!(bars(20, 80), [true, false, false, false, false]);
621 }
622
623 /// An empty diff must not divide by zero.
624 #[test]
625 fn an_empty_diff_is_all_empty() {
626 assert_eq!(bars(0, 0), [false; 5]);
627 }
628
629 #[test]
630 fn durations_read_as_durations() {
631 assert_eq!(humanise_minutes(42), "42m");
632 assert_eq!(humanise_minutes(150), "2h");
633 assert_eq!(humanise_minutes(60 * 24 * 3), "3d");
634 }
635}
636
637/// Disambiguation page for an ambiguous change-id prefix (spec §7).
638pub fn ambiguous(ctx: &RepoContext, prefix: &str, candidates: &[(i64, String, String)]) -> Markup {
639 let base = ctx.base();
640 html! {
641 div .panel {
642 h1 { "Ambiguous change id" }
643 p .lede {
644 "More than one change starts with " code { (prefix) } ". Pick one:"
645 }
646 div .stack style="gap:0" {
647 @for (number, change_id, title) in candidates {
648 div style="padding:10px 0;border-bottom:1px solid var(--border)" {
649 a href=(format!("{base}/changes/{number}")) { (title) }
650 div .row style="margin-top:4px;gap:8px" {
651 span .chip.chip-change { (&change_id[..16.min(change_id.len())]) }
652 span .faint { "#" (number) }
653 }
654 }
655 }
656 }
657 }
658 }
659}
660
661#[cfg(test)]
662mod tests {
663 use super::*;
664
665 #[test]
666 fn synthetic_changes_render_without_a_change_id() {
667 // Spec §4: "Do not pretend a synthetic identity is a real change ID."
668 let synthetic = change_chip("ppwkwxvrwvxxyttp0000000000000000", true).into_string();
669 assert!(synthetic.contains("git"));
670 assert!(
671 !synthetic.contains("cid-p"),
672 "a synthetic id must not get the identity treatment: {synthetic}"
673 );
674
675 // A real id is split into its shortest-prefix half and the remainder,
676 // so the two carry different weight — but together they are still the
677 // twelve characters the product displays.
678 let real = change_chip("klxqnvpqlnlvtkmuqmtmxktlnvnomvwv", false).into_string();
679 assert!(real.contains(r#"class="cid-p">klxq<"#), "{real}");
680 assert!(real.contains(r#"class="cid-r">nvpqlnlv<"#), "{real}");
681 }
682
683 #[test]
684 fn a_conflicted_change_shows_the_conflict_badge_regardless_of_state() {
685 let m = state_badge("open", true).into_string();
686 assert!(m.contains("badge-conflict"));
687 let m = state_badge("merged", true).into_string();
688 assert!(m.contains("badge-conflict"), "conflict must show on merged too");
689 }
690
691 #[test]
692 fn unknown_states_fall_back_to_open() {
693 let m = state_badge("something-new", false).into_string();
694 assert!(m.contains("badge-open"));
695 }
696}
697
698// ─── opening a change (M3) ───────────────────────────────────────────────────
699
700/// A change that could be proposed for review.
701pub struct Proposable {
702 pub change_id: String,
703 pub number: i64,
704 pub title: String,
705 pub synthetic: bool,
706 pub revisions: i64,
707 pub state: String,
708}
709
710/// The "open a change" form.
711///
712/// The wording is deliberate: the work already exists in the repository, and
713/// this form proposes it. A jj forge that claimed to *create* a change here
714/// would be describing something that already happened at push time.
715pub fn new_change_form(
716 ctx: &RepoContext,
717 csrf: &str,
718 candidates: &[Proposable],
719 bookmarks: &[String],
720 error: Option<&str>,
721) -> Markup {
722 let base = ctx.base();
723 html! {
724 div .panel {
725 h1 { "Open a change" }
726 p .lede {
727 "Pushed work becomes a change the moment Dogfood sees its change id. \
728 Opening one gives it a target, a title, and a description so it can be \
729 reviewed."
730 }
731
732 @if let Some(e) = error { div .banner.banner-error role="alert" { (e) } }
733
734 @if candidates.is_empty() {
735 div .empty {
736 h2 { "Nothing to open" }
737 p { "Push with " code { "jj git push" } " and the work appears here." }
738 }
739 } @else {
740 form method="post" action=(format!("{base}/changes")) .stack {
741 input type="hidden" name="_csrf" value=(csrf);
742
743 div .field {
744 label for="change" { "Change" }
745 select id="change" name="change" required {
746 @for c in candidates {
747 option value=(c.change_id) {
748 "#" (c.number) " · " (c.title)
749 @if c.state == "draft" { " (draft)" }
750 @if c.revisions > 1 { " · " (c.revisions) " revisions" }
751 @if c.synthetic { " · git" }
752 }
753 }
754 }
755 }
756
757 div .field {
758 label for="target_bookmark" { "Target bookmark" }
759 select id="target_bookmark" name="target_bookmark" required {
760 @for b in bookmarks {
761 option value=(b) selected[*b == ctx.repo.default_bookmark] { (b) }
762 }
763 }
764 p .hint { "Where this change is measured against, and where it lands." }
765 }
766
767 div .field {
768 label for="title" { "Title" }
769 input type="text" id="title" name="title" required maxlength="300"
770 placeholder="Defaults to the description of the top commit.";
771 }
772
773 div .field {
774 label for="description" { "Description" }
775 textarea id="description" name="description" rows="6"
776 placeholder="What this change does and why. Reviewers read this first." {}
777 }
778
779 button .btn.btn-primary type="submit" { "Open change" }
780 }
781 }
782 }
783 }
784}

784 lines · Rust