use gpui::{
AnyElement, App, IntoElement, ParentElement, RenderOnce, SharedString, Styled, Window, div,
prelude::FluentBuilder, px,
};
use gpui_kit_semantics::{NodeSpec, Role, Semantic};
use gpui_kit_theme::{ActiveTheme, Space, TextTone, Theme, TypeScale};
use crate::display::badge::Tone;
use crate::display::progress::ProgressBar;
use crate::display::status::StatusDot;
use crate::foundation::{Ident, StyledExt, text};
use crate::strings::{ActiveStrings, StringKey};
const RAIL: f32 = 16.0;
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub enum StepState {
#[default]
Pending,
Running,
Done,
Failed(SharedString),
Skipped(SharedString),
}
impl StepState {
pub fn as_str(&self) -> &'static str {
match self {
Self::Pending => "pending",
Self::Running => "running",
Self::Done => "done",
Self::Failed(_) => "failed",
Self::Skipped(_) => "skipped",
}
}
pub fn reason(&self) -> Option<&SharedString> {
match self {
Self::Failed(reason) | Self::Skipped(reason) => Some(reason),
_ => None,
}
}
pub fn tone(&self) -> Tone {
match self {
Self::Pending => Tone::Neutral,
Self::Running => Tone::Accent,
Self::Done => Tone::Success,
Self::Failed(_) => Tone::Danger,
Self::Skipped(_) => Tone::Warning,
}
}
fn key(&self) -> StringKey {
match self {
Self::Pending => StringKey::AgentPending,
Self::Running => StringKey::AgentRunning,
Self::Done => StringKey::AgentDone,
Self::Failed(_) => StringKey::AgentFailed,
Self::Skipped(_) => StringKey::AgentSkipped,
}
}
}
pub struct Step {
id: SharedString,
title: SharedString,
state: StepState,
body: Option<AnyElement>,
}
impl std::fmt::Debug for Step {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("Step")
.field("id", &self.id)
.field("title", &self.title)
.field("state", &self.state)
.field("has_body", &self.body.is_some())
.finish()
}
}
impl Step {
pub fn new(id: impl Into<SharedString>, title: impl Into<SharedString>) -> Self {
Self {
id: id.into(),
title: title.into(),
state: StepState::Pending,
body: None,
}
}
pub fn state(mut self, state: StepState) -> Self {
self.state = state;
self
}
pub fn body(mut self, body: impl IntoElement) -> Self {
self.body = Some(body.into_any_element());
self
}
pub fn id(&self) -> &SharedString {
&self.id
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum RunLength {
#[default]
Known,
Unknown,
}
impl RunLength {
pub fn as_str(self) -> &'static str {
match self {
Self::Known => "known",
Self::Unknown => "unknown",
}
}
}
#[derive(IntoElement)]
pub struct StepList {
ident: Ident,
steps: Vec<Step>,
length: RunLength,
}
impl std::fmt::Debug for StepList {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("StepList")
.field("ident", &self.ident)
.field("steps", &self.steps.len())
.field("length", &self.length)
.finish()
}
}
impl StepList {
pub fn new(ident: impl Into<Ident>) -> Self {
Self {
ident: ident.into(),
steps: Vec::new(),
length: RunLength::Known,
}
}
pub fn step(mut self, step: Step) -> Self {
self.steps.push(step);
self
}
pub fn steps(mut self, steps: impl IntoIterator<Item = Step>) -> Self {
self.steps.extend(steps);
self
}
pub fn length(mut self, length: RunLength) -> Self {
self.length = length;
self
}
fn done(&self) -> usize {
self.steps
.iter()
.filter(|step| matches!(step.state, StepState::Done))
.count()
}
}
impl RenderOnce for StepList {
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme().clone();
let ident = self.ident.clone();
let total = self.steps.len();
let done = self.done();
let summary = ProgressBar::new(ident.child("progress"));
let summary = match self.length {
RunLength::Known => summary.count(done, total),
RunLength::Unknown => summary.display(if done == 1 {
cx.strings().text(StringKey::AgentStepsDoneOne)
} else {
cx.strings()
.format(StringKey::AgentStepsDoneMany, &[&done.to_string()])
}),
};
let last = total.saturating_sub(1);
let mut run = div().w_full().column().gap_token(&theme, Space::Md);
for (index, step) in self.steps.into_iter().enumerate() {
run = run.child(step_element(&ident, &theme, step, index < last, cx));
}
div()
.w_full()
.column()
.gap_token(&theme, Space::Md)
.child(summary)
.child(run)
.semantic_in(
cx,
NodeSpec::new(ident.semantic_id(), Role::List)
.value(total.to_string())
.busy(matches!(self.length, RunLength::Unknown)),
)
}
}
fn step_element(
list: &Ident,
theme: &Theme,
step: Step,
continues: bool,
cx: &mut App,
) -> AnyElement {
let ident = list.child(step.id.as_ref());
let running = matches!(step.state, StepState::Running);
let rail = div()
.w(px(RAIL))
.flex_none()
.column()
.items_center()
.child(div().mt(px(4.0)).child({
let dot = StatusDot::new(step.state.tone());
if running {
dot.busy(ident.child("mark"))
} else {
dot
}
}))
.when(continues, |element| {
element.child(
div()
.mt(px(4.0))
.w(px(theme.borders.hairline))
.flex_1()
.min_h(px(theme.space(Space::Md)))
.bg(theme.colors.hairline),
)
});
let reason = step.state.reason().cloned().map(|reason| {
let state = step.state.as_str();
text(theme, TypeScale::Body, reason.clone())
.text_color(step.state.tone().color(theme))
.semantic_in(
cx,
NodeSpec::new(ident.child("reason").semantic_id(), Role::Status)
.parent(ident.semantic_id())
.text(reason)
.value(state),
)
});
div()
.row()
.items_start()
.w_full()
.gap_token(theme, Space::Sm)
.child(rail)
.child(
div()
.column()
.flex_1()
.min_w_0()
.gap(px(2.0))
.child(
div()
.row()
.gap_token(theme, Space::Sm)
.child(
text(theme, TypeScale::Label, step.title.clone())
.flex_1()
.min_w_0(),
)
.child(
text(
theme,
TypeScale::Caption,
cx.strings().text(step.state.key()),
)
.flex_none()
.text_tone(theme, TextTone::Faint),
),
)
.children(reason)
.children(
step.body
.map(|body| div().mt_token(theme, Space::Xs).child(body)),
),
)
.semantic_in(
cx,
NodeSpec::new(ident.semantic_id(), Role::Row)
.parent(list.semantic_id())
.text(step.title.clone())
.value(step.state.as_str())
.busy(running),
)
.into_any_element()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_state_publishes_its_own_name() {
let names = [
StepState::Pending.as_str(),
StepState::Running.as_str(),
StepState::Done.as_str(),
StepState::Failed("boom".into()).as_str(),
StepState::Skipped("nothing to do".into()).as_str(),
];
let mut unique = names.to_vec();
unique.sort_unstable();
unique.dedup();
assert_eq!(unique.len(), names.len());
}
#[test]
fn only_a_state_the_host_explained_carries_a_reason() {
assert!(StepState::Done.reason().is_none());
assert_eq!(
StepState::Skipped("nothing to do".into())
.reason()
.map(SharedString::to_string),
Some("nothing to do".to_string())
);
}
}