1use crate::control::ControlMetricsExt as _;
11use crate::control::QuietSurfaceExt as _;
12use crate::decoration::{DecoratedExt as _, Decoration};
13use crate::{
14 ButtonLabelExt as _,
15 control::composed_button,
16 cues::{self, Cue},
17 handlers::SharedHandler,
18 motion::{ArrivalRoster, MotionTokens, acknowledged_state},
19 status::{StatusBadge, StatusTone},
20 surface::{card, description, eyebrow, meta, title},
21 theme::SemanticStyledExt as _,
22};
23use gpui::{
24 AnyElement, App, ClickEvent, ElementId, FontWeight, InteractiveElement as _, IntoElement,
25 ParentElement as _, RenderOnce, Role, SharedString, StatefulInteractiveElement as _,
26 StyleRefinement, Styled, Window, div, prelude::FluentBuilder as _,
27};
28use gpui_component::{
29 ActiveTheme as _, Icon, IconName, Sizable as _, StyledExt as _,
30 button::{Button, ButtonVariants as _},
31 h_flex,
32 spinner::Spinner,
33 v_flex,
34};
35use std::rc::Rc;
36
37#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
39pub enum PlanStepStatus {
40 #[default]
42 Pending,
43 Running,
45 Done,
47 Failed,
49 Skipped,
51}
52
53impl PlanStepStatus {
54 pub fn label(self) -> &'static str {
56 match self {
57 Self::Pending => "pending",
58 Self::Running => "running",
59 Self::Done => "done",
60 Self::Failed => "failed",
61 Self::Skipped => "skipped",
62 }
63 }
64}
65
66#[derive(Debug, Clone, PartialEq, Eq)]
68pub struct PlanStep {
69 id: SharedString,
70 title: SharedString,
71 detail: Option<SharedString>,
72 status: PlanStepStatus,
73}
74
75impl PlanStep {
76 pub fn new(id: impl Into<SharedString>, title: impl Into<SharedString>) -> Self {
78 Self {
79 id: id.into(),
80 title: title.into(),
81 detail: None,
82 status: PlanStepStatus::Pending,
83 }
84 }
85
86 pub fn detail(mut self, detail: impl Into<SharedString>) -> Self {
88 self.detail = Some(detail.into());
89 self
90 }
91
92 pub fn status(mut self, status: PlanStepStatus) -> Self {
94 self.status = status;
95 self
96 }
97
98 pub fn id(&self) -> &SharedString {
100 &self.id
101 }
102
103 pub fn title(&self) -> &SharedString {
105 &self.title
106 }
107
108 pub fn detail_text(&self) -> Option<&SharedString> {
110 self.detail.as_ref()
111 }
112
113 pub fn step_status(&self) -> PlanStepStatus {
115 self.status
116 }
117}
118
119#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
121pub enum PlanState {
122 #[default]
124 Proposed,
125 Approved,
127 Rejected,
129 Running,
131 Completed,
133 Failed,
135}
136
137impl PlanState {
138 pub fn label(self) -> &'static str {
140 match self {
141 Self::Proposed => "Proposed",
142 Self::Approved => "Approved",
143 Self::Rejected => "Rejected",
144 Self::Running => "Running",
145 Self::Completed => "Completed",
146 Self::Failed => "Failed",
147 }
148 }
149
150 fn tone(self) -> StatusTone {
151 match self {
152 Self::Proposed | Self::Running => StatusTone::Info,
153 Self::Approved | Self::Completed => StatusTone::Success,
154 Self::Rejected => StatusTone::Neutral,
155 Self::Failed => StatusTone::Danger,
156 }
157 }
158}
159
160#[derive(Debug, Clone, PartialEq, Eq)]
162pub enum PlanEvent {
163 Approved {
165 id: SharedString,
167 },
168 Rejected {
170 id: SharedString,
172 },
173 EditRequested {
175 id: SharedString,
177 },
178 StepActivated {
180 id: SharedString,
182 step_id: SharedString,
184 },
185}
186
187#[derive(IntoElement)]
204pub struct PlanCard {
205 id: SharedString,
206 style: StyleRefinement,
207 decoration: Decoration,
208 title: SharedString,
209 description: Option<SharedString>,
210 steps: Vec<PlanStep>,
211 state: PlanState,
212 note: Option<SharedString>,
213 editable: bool,
214 on_event: Option<SharedHandler<PlanEvent>>,
215}
216
217impl PlanCard {
218 pub fn decoration(mut self, decoration: Decoration) -> Self {
224 self.decoration = decoration;
225 self
226 }
227
228 pub fn new(id: impl Into<SharedString>, title: impl Into<SharedString>) -> Self {
230 Self {
231 id: id.into(),
232 style: StyleRefinement::default(),
233 decoration: Decoration::default(),
234 title: title.into(),
235 description: None,
236 steps: Vec::new(),
237 state: PlanState::Proposed,
238 note: None,
239 editable: false,
240 on_event: None,
241 }
242 }
243
244 pub fn description(mut self, description: impl Into<SharedString>) -> Self {
246 self.description = Some(description.into());
247 self
248 }
249
250 pub fn steps(mut self, steps: impl IntoIterator<Item = PlanStep>) -> Self {
252 self.steps = steps.into_iter().collect();
253 self
254 }
255
256 pub fn state(mut self, state: PlanState) -> Self {
258 self.state = state;
259 self
260 }
261
262 pub fn note(mut self, note: impl Into<SharedString>) -> Self {
264 self.note = Some(note.into());
265 self
266 }
267
268 pub fn editable(mut self, editable: bool) -> Self {
270 self.editable = editable;
271 self
272 }
273
274 pub fn on_event(
276 mut self,
277 handler: impl Fn(&PlanEvent, &mut Window, &mut App) + 'static,
278 ) -> Self {
279 self.on_event = Some(Rc::new(handler));
280 self
281 }
282
283 fn done_count(&self) -> usize {
285 self.steps
286 .iter()
287 .filter(|step| step.status == PlanStepStatus::Done)
288 .count()
289 }
290}
291
292impl Styled for PlanCard {
293 fn style(&mut self) -> &mut StyleRefinement {
294 &mut self.style
295 }
296}
297
298impl RenderOnce for PlanCard {
299 fn render(mut self, window: &mut Window, cx: &mut App) -> impl IntoElement {
300 let tokens = cx.theme().semantic_tokens();
301 let mut decoration = std::mem::take(&mut self.decoration);
304 let done = self.done_count();
305 let handler = self.on_event;
306 let plan_id = self.id.clone();
307 let total = self.steps.len();
308 let label: SharedString = format!(
309 "Plan: {}, {done} of {total} steps done, {}",
310 self.title,
311 self.state.label().to_ascii_lowercase()
312 )
313 .into();
314 let root_id = ElementId::from(self.id.clone());
315 let debug_id = self.id.to_string();
316
317 let motion = MotionTokens::read(cx).clone();
323 let roster = window.use_keyed_state((root_id.clone(), "arrivals"), cx, |_, _| {
324 ArrivalRoster::new()
325 });
326 roster.update(cx, |roster, cx| {
327 roster.note(
328 self.steps.iter().map(|step| {
329 ElementId::Name(SharedString::from(format!("plan-step-{}", step.id)))
330 }),
331 true,
332 &motion,
333 cx.background_executor().now(),
334 );
335 });
336 let mut steps = Vec::with_capacity(total);
337 for (index, step) in self.steps.iter().enumerate() {
338 let arrival = roster.update(cx, |roster, cx| {
339 roster.progress(
340 &ElementId::Name(SharedString::from(format!("plan-step-{}", step.id))),
341 window,
342 cx,
343 )
344 });
345 steps.push(render_step(
346 &root_id,
347 &plan_id,
348 index,
349 total,
350 step,
351 arrival,
352 handler.clone(),
353 window,
354 cx,
355 ));
356 }
357
358 let footer: Option<AnyElement> = match (self.state, handler) {
359 (PlanState::Proposed, Some(handler)) => {
360 let approve_id = plan_id.clone();
361 let reject_id = plan_id.clone();
362 let edit_id = plan_id.clone();
363 let approve_handler = handler.clone();
364 let reject_handler = handler.clone();
365 let approve_debug = debug_id.clone();
366 let reject_debug = debug_id.clone();
367 let edit_debug = debug_id.clone();
368 Some(
369 h_flex()
370 .items_center()
371 .gap(tokens.spacing.sm)
372 .child(
373 div()
374 .debug_selector(move || format!("plan-approve-{approve_debug}"))
375 .child(
376 Button::new((root_id.clone(), "approve"))
377 .primary()
378 .small()
379 .control_metrics(cx)
380 .accessibility_id(format!("{plan_id}-approve"))
381 .text_label("Approve plan")
382 .on_click(move |_: &ClickEvent, window, cx| {
383 cues::emit(cx, Cue::Decided { approved: true });
384 approve_handler(
385 &PlanEvent::Approved {
386 id: approve_id.clone(),
387 },
388 window,
389 cx,
390 )
391 }),
392 ),
393 )
394 .child(
395 div()
396 .debug_selector(move || format!("plan-reject-{reject_debug}"))
397 .child(
398 Button::new((root_id.clone(), "reject"))
399 .outline()
400 .small()
401 .control_metrics(cx)
402 .accessibility_id(format!("{plan_id}-reject"))
403 .text_label("Reject")
404 .on_click(move |_: &ClickEvent, window, cx| {
405 cues::emit(cx, Cue::Decided { approved: false });
406 reject_handler(
407 &PlanEvent::Rejected {
408 id: reject_id.clone(),
409 },
410 window,
411 cx,
412 )
413 }),
414 ),
415 )
416 .when(self.editable, |this| {
417 this.child(
418 div()
419 .debug_selector(move || format!("plan-edit-{edit_debug}"))
420 .child(
421 Button::new((root_id.clone(), "edit"))
422 .ghost()
423 .small()
424 .control_metrics(cx)
425 .accessibility_id(format!("{plan_id}-edit"))
426 .text_label("Edit plan")
427 .on_click(move |_: &ClickEvent, window, cx| {
428 handler(
429 &PlanEvent::EditRequested {
430 id: edit_id.clone(),
431 },
432 window,
433 cx,
434 )
435 }),
436 ),
437 )
438 })
439 .into_any_element(),
440 )
441 }
442 _ => self
443 .note
444 .clone()
445 .map(|note| meta(note, cx).into_any_element()),
446 };
447
448 card(self.id.clone(), cx)
449 .decoration_under(&mut decoration)
450 .role(Role::Group)
451 .aria_label(label)
452 .when_some(self.description.clone(), |this, text| {
453 this.aria_description(text)
454 })
455 .debug_selector(move || format!("plan-{debug_id}"))
456 .when(self.state == PlanState::Proposed, |this| {
457 this.border_color(cx.theme().warning)
458 })
459 .child(
460 h_flex()
461 .items_center()
462 .justify_between()
463 .gap(tokens.spacing.sm)
464 .child(eyebrow("Plan", cx))
465 .child(
466 h_flex()
467 .items_center()
468 .gap(tokens.spacing.sm)
469 .child(meta(format!("{done} / {total}"), cx))
470 .child(
471 StatusBadge::new(
472 (ElementId::from(self.id.clone()), "state"),
473 self.state.label(),
474 )
475 .tone(self.state.tone())
476 .active(self.state == PlanState::Running),
477 ),
478 ),
479 )
480 .child(title(self.title, cx))
481 .when_some(self.description, |this, text| {
482 this.child(description(text, cx))
483 })
484 .child(
485 v_flex()
486 .id((ElementId::from(self.id.clone()), "steps"))
487 .role(Role::List)
488 .aria_label("Steps")
489 .w_full()
490 .min_w_0()
491 .children(steps),
492 )
493 .children(footer)
494 .decoration_over(&mut decoration)
495 .refine_style(&self.style)
496 }
497}
498
499#[allow(clippy::too_many_arguments)]
500fn render_step(
501 root_id: &ElementId,
502 plan_id: &SharedString,
503 index: usize,
504 total: usize,
505 step: &PlanStep,
506 arrival: Option<f32>,
507 handler: Option<SharedHandler<PlanEvent>>,
508 window: &mut Window,
509 cx: &mut App,
510) -> AnyElement {
511 let tokens = cx.theme().semantic_tokens();
512 let step_id = ElementId::from((root_id.clone(), format!("step-{}", step.id)));
513 let plan_debug = plan_id.clone();
514 let step_debug = step.id.clone();
515 let label: SharedString = format!(
516 "Step {}: {}, {}",
517 index + 1,
518 step.title,
519 step.status.label()
520 )
521 .into();
522 let is_last = index + 1 == total;
523
524 let glyph_size = tokens.spacing.lg;
525 let acknowledged = acknowledged_state(
529 ElementId::from((step_id.clone(), "glyph")),
530 step.status as u64,
531 window,
532 cx,
533 );
534 let (glyph, ring, fill): (AnyElement, gpui::Hsla, gpui::Hsla) = match step.status {
535 PlanStepStatus::Pending => (
536 div()
537 .text_token(tokens.typography.xs)
538 .font_weight(FontWeight::SEMIBOLD)
539 .text_color(cx.theme().muted_foreground)
540 .child((index + 1).to_string())
541 .into_any_element(),
542 cx.theme().border,
543 cx.theme().transparent,
544 ),
545 PlanStepStatus::Running => (
546 Spinner::new().xsmall().into_any_element(),
547 cx.theme().primary,
548 cx.theme().primary.opacity(0.1),
549 ),
550 PlanStepStatus::Done => (
551 Icon::new(IconName::Check)
552 .xsmall()
553 .text_color(cx.theme().success)
554 .opacity(acknowledged)
555 .into_any_element(),
556 cx.theme().success.opacity(0.5),
557 cx.theme().success.opacity(0.12),
558 ),
559 PlanStepStatus::Failed => (
560 Icon::new(IconName::CircleX)
561 .xsmall()
562 .text_color(cx.theme().danger)
563 .opacity(acknowledged)
564 .into_any_element(),
565 cx.theme().danger.opacity(0.5),
566 cx.theme().danger.opacity(0.12),
567 ),
568 PlanStepStatus::Skipped => (
569 Icon::new(IconName::Dash)
570 .xsmall()
571 .text_color(cx.theme().muted_foreground)
572 .opacity(acknowledged)
573 .into_any_element(),
574 cx.theme().border,
575 cx.theme().muted.opacity(0.5),
576 ),
577 };
578 let rail = v_flex()
579 .flex_none()
580 .items_center()
581 .w(glyph_size)
582 .child(
583 div()
584 .flex_none()
585 .size(glyph_size)
586 .flex()
587 .items_center()
588 .justify_center()
589 .rounded(tokens.radius.full)
590 .border_1()
591 .border_color(ring)
592 .bg(fill)
593 .child(glyph),
594 )
595 .when(!is_last, |this| {
596 this.child(
597 div()
598 .flex_1()
599 .min_h(tokens.spacing.sm)
600 .border_l_1()
601 .border_color(cx.theme().border),
602 )
603 });
604 let body = div()
605 .flex()
606 .flex_col()
607 .min_w_0()
608 .flex_1()
609 .gap(tokens.spacing.xxs)
610 .pb(if is_last {
611 tokens.spacing.xxs
612 } else {
613 tokens.spacing.sm
614 })
615 .child(
616 div()
617 .text_token(tokens.typography.sm)
618 .text_color(match step.status {
619 PlanStepStatus::Skipped => cx.theme().muted_foreground,
620 _ => cx.theme().foreground,
621 })
622 .child(step.title.clone()),
623 )
624 .when_some(step.detail.clone(), |this, detail| {
625 this.child(crate::surface::hint(detail, cx))
626 });
627
628 let row = match handler {
629 Some(handler) => {
630 let event = PlanEvent::StepActivated {
631 id: plan_id.clone(),
632 step_id: step.id.clone(),
633 };
634 composed_button(step_id.clone(), label)
635 .debug_selector(move || format!("plan-step-{plan_debug}-{step_debug}"))
636 .flex()
637 .items_stretch()
638 .w_full()
639 .min_w_0()
640 .gap(tokens.spacing.sm)
641 .quiet_press_surface(
642 ElementId::from((step_id, "press")),
643 tokens.radius.sm,
644 window,
645 cx,
646 )
647 .child(rail)
648 .child(body)
649 .on_click(move |_: &ClickEvent, window, cx| handler(&event, window, cx))
650 .into_any_element()
651 }
652 None => h_flex()
653 .id(step_id)
654 .role(Role::ListItem)
655 .aria_label(label)
656 .debug_selector(move || format!("plan-step-{plan_debug}-{step_debug}"))
657 .items_stretch()
658 .w_full()
659 .min_w_0()
660 .gap(tokens.spacing.sm)
661 .child(rail)
662 .child(body)
663 .into_any_element(),
664 };
665 let wrapped = div().w_full().min_w_0().child(row);
666 match arrival {
667 Some(progress) => wrapped
670 .opacity(progress)
671 .top(tokens.spacing.xxs * (1.0 - progress) * crate::motion::travel(cx))
672 .into_any_element(),
673 None => wrapped.into_any_element(),
674 }
675}
676
677#[cfg(test)]
678mod tests {
679 use super::*;
680 use gpui::{Context, Render, TestAppContext, VisualTestContext, px};
681
682 struct PlanProbe {
683 count: usize,
684 }
685
686 impl Render for PlanProbe {
687 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
688 div().w(px(360.)).h(px(480.)).child(
689 PlanCard::new("probe-plan", "Rollout")
690 .steps((0..self.count).map(|ix| PlanStep::new(format!("step-{ix}"), "Step"))),
691 )
692 }
693 }
694
695 #[gpui::test]
696 fn a_proposed_plan_mounts_at_rest_and_new_steps_settle_in(cx: &mut TestAppContext) {
697 cx.update(crate::init);
698 let (probe, cx) = cx.add_window_view(|_, _| PlanProbe { count: 3 });
699 let cx: &mut VisualTestContext = cx;
700 cx.update(|window, cx| window.draw(cx).clear(cx));
701 crate::motion::take_reveal_frame_requests();
702 cx.update(|window, cx| window.draw(cx).clear(cx));
703 assert_eq!(
704 crate::motion::take_reveal_frame_requests(),
705 0,
706 "a plan's initial steps are a proposal to read, not a cascade"
707 );
708
709 probe.update(cx, |probe, cx| {
710 probe.count = 4;
711 cx.notify();
712 });
713 cx.update(|window, cx| window.draw(cx).clear(cx));
714 assert!(
715 crate::motion::take_reveal_frame_requests() > 0,
716 "a step appended to a mounted plan must settle in"
717 );
718 }
719
720 #[test]
721 fn plan_state_labels_and_tones_follow_the_lifecycle() {
722 assert_eq!(PlanState::Proposed.label(), "Proposed");
723 assert_eq!(PlanState::Completed.tone(), StatusTone::Success);
724 assert_eq!(PlanState::Failed.tone(), StatusTone::Danger);
725 assert_eq!(PlanState::Rejected.tone(), StatusTone::Neutral);
726 }
727
728 #[test]
729 fn done_steps_are_counted_for_the_accessible_name() {
730 let card = PlanCard::new("p", "Plan").steps([
731 PlanStep::new("a", "A").status(PlanStepStatus::Done),
732 PlanStep::new("b", "B").status(PlanStepStatus::Running),
733 PlanStep::new("c", "C"),
734 ]);
735 assert_eq!(card.done_count(), 1);
736 }
737}