1use crate::stream::{ProgressState, Progressive};
4use crate::theme::SemanticStyledExt as _;
5use gpui::{
6 App, ElementId, InteractiveElement as _, IntoElement, ParentElement as _, RenderOnce, Role,
7 SharedString, StatefulInteractiveElement as _, StyleRefinement, Styled, Window, div,
8 prelude::FluentBuilder as _,
9};
10use gpui_component::{ActiveTheme as _, StyledExt as _, h_flex};
11use std::time::Duration;
12
13#[derive(Debug, Clone, PartialEq, Eq)]
15pub struct TaskSnapshot {
16 id: SharedString,
17 title: SharedString,
18 detail: Option<SharedString>,
19 elapsed: Option<Duration>,
20}
21
22impl TaskSnapshot {
23 pub fn new(id: impl Into<SharedString>, title: impl Into<SharedString>) -> Self {
25 Self {
26 id: id.into(),
27 title: title.into(),
28 detail: None,
29 elapsed: None,
30 }
31 }
32
33 pub fn detail(mut self, detail: impl Into<SharedString>) -> Self {
35 self.detail = Some(detail.into());
36 self
37 }
38
39 pub fn elapsed(mut self, elapsed: Duration) -> Self {
41 self.elapsed = Some(elapsed);
42 self
43 }
44
45 pub fn id(&self) -> &str {
47 self.id.as_ref()
48 }
49}
50
51#[derive(IntoElement)]
66pub struct TaskRow {
67 style: StyleRefinement,
68 task: TaskSnapshot,
69 state: ProgressState,
70}
71
72impl TaskRow {
73 pub fn new(task: &Progressive<TaskSnapshot>) -> Self {
75 Self {
76 style: StyleRefinement::default(),
77 task: task.content().clone(),
78 state: task.state().clone(),
79 }
80 }
81}
82
83impl Styled for TaskRow {
84 fn style(&mut self) -> &mut StyleRefinement {
85 &mut self.style
86 }
87}
88
89impl RenderOnce for TaskRow {
90 fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
91 let tokens = cx.theme().semantic_tokens();
92 let indicator = crate::status::progress_glyph(
96 &self.state,
97 ElementId::from((ElementId::from(self.task.id.clone()), "task-glyph")),
98 crate::sizing::slot_md(cx),
99 window,
100 cx,
101 )
102 .into_any_element();
103 let failed_reason = match &self.state {
104 ProgressState::Failed(reason) => Some(reason.clone()),
105 _ => None,
106 };
107 let state_label = match &self.state {
108 ProgressState::Pending => "pending",
109 ProgressState::Running => "in progress",
110 ProgressState::Complete => "complete",
111 ProgressState::Failed(_) => "failed",
112 };
113 let accessibility_label = format!("{}, {state_label}", self.task.title);
114 let accessibility_description = failed_reason.clone().or_else(|| self.task.detail.clone());
115
116 h_flex()
117 .id(self.task.id.clone())
118 .role(Role::ListItem)
119 .aria_label(accessibility_label)
120 .when_some(accessibility_description, |this, description| {
121 this.aria_description(description)
122 })
123 .w_full()
124 .items_center()
125 .gap(tokens.spacing.sm)
126 .py(tokens.spacing.xs)
127 .text_token(tokens.typography.sm)
128 .child(indicator)
131 .child(
132 div()
133 .flex_1()
134 .truncate()
135 .text_color(match self.state {
136 ProgressState::Pending => cx.theme().muted_foreground,
137 _ => cx.theme().foreground,
138 })
139 .child(self.task.title),
140 )
141 .when_some(self.task.elapsed, |this, elapsed| {
142 this.child(
143 div()
144 .flex_none()
145 .text_token(tokens.typography.xs)
146 .font_family(cx.theme().mono_font_family.clone())
147 .text_color(cx.theme().muted_foreground)
148 .child(format_elapsed(elapsed)),
149 )
150 })
151 .when_some(failed_reason.or(self.task.detail), |this, detail| {
152 this.child(
155 div()
156 .min_w_0()
157 .truncate()
158 .text_token(tokens.typography.xs)
159 .text_color(match self.state {
160 ProgressState::Failed(_) => cx.theme().danger,
161 _ => cx.theme().muted_foreground,
162 })
163 .child(detail),
164 )
165 })
166 .refine_style(&self.style)
167 }
168}
169
170fn format_elapsed(elapsed: Duration) -> String {
171 let secs = elapsed.as_secs_f64();
172 if secs < 10.0 {
173 format!("{secs:.1}s")
174 } else if secs < 60.0 {
175 format!("{secs:.0}s")
176 } else {
177 let minutes = (secs / 60.0).floor() as u64;
178 let rest = secs as u64 % 60;
179 format!("{minutes}m {rest:02}s")
180 }
181}
182
183#[cfg(test)]
184mod tests {
185 use super::*;
186 use gpui::{Context, Render, TestAppContext};
187
188 struct LifecycleProbe {
189 state: ProgressState,
190 }
191
192 impl Render for LifecycleProbe {
193 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
194 TaskRow {
195 task: TaskSnapshot::new("task", "Index repository"),
196 state: self.state.clone(),
197 style: StyleRefinement::default(),
198 }
199 }
200 }
201
202 #[gpui::test]
203 fn running_to_complete_acknowledges_the_controlled_change(cx: &mut TestAppContext) {
204 cx.update(crate::init);
205 let (probe, cx) = cx.add_window_view(|_, _| LifecycleProbe {
206 state: ProgressState::Running,
207 });
208 cx.update(|window, cx| window.draw(cx).clear(cx));
209 crate::motion::take_reveal_frame_requests();
210
211 probe.update(cx, |probe, cx| {
212 probe.state = ProgressState::Complete;
213 cx.notify();
214 });
215 cx.update(|window, cx| window.draw(cx).clear(cx));
216 assert!(
217 crate::motion::take_reveal_frame_requests() > 0,
218 "completion is a state change, not the glyph's first mount"
219 );
220 }
221
222 #[test]
223 fn elapsed_formatting() {
224 assert_eq!(format_elapsed(Duration::from_millis(400)), "0.4s");
225 assert_eq!(format_elapsed(Duration::from_secs(12)), "12s");
226 assert_eq!(format_elapsed(Duration::from_secs(125)), "2m 05s");
227 }
228
229 #[test]
230 fn row_maps_the_shared_lifecycle() {
231 let task = TaskSnapshot::new("index", "Index repository");
232 for (progress, state) in [
233 (Progressive::pending(task.clone()), ProgressState::Pending),
234 (Progressive::running(task.clone()), ProgressState::Running),
235 (Progressive::complete(task.clone()), ProgressState::Complete),
236 (
237 Progressive::failed(task.clone(), "disk unavailable"),
238 ProgressState::Failed("disk unavailable".into()),
239 ),
240 ] {
241 let row = TaskRow::new(&progress);
242 assert_eq!(row.state, state);
243 assert_eq!(row.task.id(), "index");
244 }
245 }
246}