Skip to main content

gpui_kit/data/
diagnostics_list.rs

1//! A product-neutral diagnostic surface over caller-owned data.
2//!
3//! This component reports selection, filter, action, and retry intents. It
4//! never changes the supplied diagnostics, opens a location, or runs a fix.
5
6use std::rc::Rc;
7
8use gpui::{App, IntoElement, ParentElement, RenderOnce, SharedString, Styled, Window, div, px};
9use gpui_kit_semantics::{NodeSpec, Role, Semantic};
10use gpui_kit_theme::{ActiveTheme, ControlSize, Space, TextTone, TypeScale};
11
12use crate::controls::button::Button;
13use crate::controls::filter_bar::{FilterBar, FilterCondition, ResultCount};
14use crate::controls::toggle_button::Toggle;
15use crate::data::list::{List, ListItem};
16use crate::display::badge::{Badge, Tone};
17use crate::display::empty::{EmptyKind, EmptyState};
18use crate::display::loading::PulseLoader;
19use crate::foundation::{Disableable, Ident, Sizable, StyledExt, text};
20use crate::state::Loadable;
21use crate::strings::{ActiveStrings, StringKey};
22
23type FilterHandler = Rc<dyn Fn(DiagnosticFilter, &mut Window, &mut App)>;
24type SelectHandler = Rc<dyn Fn(SharedString, &mut Window, &mut App)>;
25type ActionHandler = Rc<dyn Fn(SharedString, SharedString, &mut Window, &mut App)>;
26type RetryHandler = Rc<dyn Fn(&mut Window, &mut App)>;
27
28/// Severity assigned by the caller.
29#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
30pub enum DiagnosticSeverity {
31    Error,
32    Warning,
33    Information,
34    Hint,
35}
36
37impl DiagnosticSeverity {
38    pub const ALL: [Self; 4] = [Self::Error, Self::Warning, Self::Information, Self::Hint];
39
40    pub fn name(self) -> &'static str {
41        match self {
42            Self::Error => "error",
43            Self::Warning => "warning",
44            Self::Information => "information",
45            Self::Hint => "hint",
46        }
47    }
48
49    pub fn tone(self) -> Tone {
50        match self {
51            Self::Error => Tone::Danger,
52            Self::Warning => Tone::Warning,
53            Self::Information => Tone::Info,
54            Self::Hint => Tone::Neutral,
55        }
56    }
57
58    fn label(self, cx: &App) -> SharedString {
59        cx.strings().text(match self {
60            Self::Error => StringKey::DiagnosticsSeverityError,
61            Self::Warning => StringKey::DiagnosticsSeverityWarning,
62            Self::Information => StringKey::DiagnosticsSeverityInformation,
63            Self::Hint => StringKey::DiagnosticsSeverityHint,
64        })
65    }
66
67    const fn bit(self) -> u8 {
68        1 << self as u8
69    }
70}
71
72/// Caller-formatted location text. No path interpretation is performed.
73#[derive(Clone, Debug, PartialEq, Eq)]
74pub struct DiagnosticLocation(SharedString);
75
76impl DiagnosticLocation {
77    pub fn new(label: impl Into<SharedString>) -> Self {
78        Self(label.into())
79    }
80    pub fn label(&self) -> &SharedString {
81        &self.0
82    }
83}
84
85/// A caller-owned action that can be reported for a diagnostic.
86#[derive(Clone, Debug, PartialEq, Eq)]
87pub struct DiagnosticAction {
88    id: SharedString,
89    label: SharedString,
90    disabled: bool,
91}
92
93impl DiagnosticAction {
94    pub fn new(id: impl Into<SharedString>, label: impl Into<SharedString>) -> Self {
95        Self {
96            id: id.into(),
97            label: label.into(),
98            disabled: false,
99        }
100    }
101    pub fn disabled(mut self, disabled: bool) -> Self {
102        self.disabled = disabled;
103        self
104    }
105    pub fn id(&self) -> &SharedString {
106        &self.id
107    }
108    pub fn label(&self) -> &SharedString {
109        &self.label
110    }
111    pub fn is_disabled(&self) -> bool {
112        self.disabled
113    }
114}
115
116/// One caller-owned diagnostic.
117#[derive(Clone, Debug, PartialEq, Eq)]
118pub struct Diagnostic {
119    id: SharedString,
120    severity: DiagnosticSeverity,
121    location: DiagnosticLocation,
122    message: SharedString,
123    actions: Vec<DiagnosticAction>,
124    disabled: bool,
125}
126
127impl Diagnostic {
128    pub fn new(
129        id: impl Into<SharedString>,
130        severity: DiagnosticSeverity,
131        location: DiagnosticLocation,
132        message: impl Into<SharedString>,
133    ) -> Self {
134        Self {
135            id: id.into(),
136            severity,
137            location,
138            message: message.into(),
139            actions: Vec::new(),
140            disabled: false,
141        }
142    }
143    pub fn action(mut self, action: DiagnosticAction) -> Self {
144        self.actions.push(action);
145        self
146    }
147    pub fn actions(mut self, actions: impl IntoIterator<Item = DiagnosticAction>) -> Self {
148        self.actions.extend(actions);
149        self
150    }
151    pub fn disabled(mut self, disabled: bool) -> Self {
152        self.disabled = disabled;
153        self
154    }
155    pub fn id(&self) -> &SharedString {
156        &self.id
157    }
158    pub fn severity(&self) -> DiagnosticSeverity {
159        self.severity
160    }
161    pub fn location(&self) -> &DiagnosticLocation {
162        &self.location
163    }
164    pub fn message(&self) -> &SharedString {
165        &self.message
166    }
167    pub fn diagnostic_actions(&self) -> &[DiagnosticAction] {
168        &self.actions
169    }
170    pub fn is_disabled(&self) -> bool {
171        self.disabled
172    }
173}
174
175/// Selected severities, stored as a compact bit set.
176#[derive(Clone, Copy, Debug, PartialEq, Eq)]
177pub struct DiagnosticFilter(u8);
178
179impl Default for DiagnosticFilter {
180    fn default() -> Self {
181        Self::all()
182    }
183}
184
185impl DiagnosticFilter {
186    const ALL_BITS: u8 = 0b1111;
187    pub const fn all() -> Self {
188        Self(Self::ALL_BITS)
189    }
190    pub const fn none() -> Self {
191        Self(0)
192    }
193    pub fn from_severities(values: impl IntoIterator<Item = DiagnosticSeverity>) -> Self {
194        Self(
195            values
196                .into_iter()
197                .fold(0, |bits, severity| bits | severity.bit()),
198        )
199    }
200    pub const fn contains(self, severity: DiagnosticSeverity) -> bool {
201        self.0 & severity.bit() != 0
202    }
203    pub fn includes(self, diagnostic: &Diagnostic) -> bool {
204        self.contains(diagnostic.severity)
205    }
206    pub const fn removing(self, severity: DiagnosticSeverity) -> Self {
207        Self(self.0 & !severity.bit())
208    }
209    pub const fn clearing(self) -> Self {
210        Self::none()
211    }
212    const fn setting(self, severity: DiagnosticSeverity, included: bool) -> Self {
213        if included {
214            Self(self.0 | severity.bit())
215        } else {
216            Self(self.0 & !severity.bit())
217        }
218    }
219    pub fn severities(self) -> impl Iterator<Item = DiagnosticSeverity> {
220        DiagnosticSeverity::ALL
221            .into_iter()
222            .filter(move |severity| self.contains(*severity))
223    }
224}
225
226/// A diagnostic list whose data and state remain owned by its caller.
227#[derive(IntoElement)]
228pub struct DiagnosticsList {
229    ident: Ident,
230    diagnostics: Loadable<Vec<Diagnostic>, SharedString>,
231    filter: DiagnosticFilter,
232    selected: Option<SharedString>,
233    visible_rows: Option<usize>,
234    size: ControlSize,
235    disabled: bool,
236    on_filter: Option<FilterHandler>,
237    on_select: Option<SelectHandler>,
238    on_action: Option<ActionHandler>,
239    on_retry: Option<RetryHandler>,
240}
241
242impl std::fmt::Debug for DiagnosticsList {
243    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
244        formatter
245            .debug_struct("DiagnosticsList")
246            .field("ident", &self.ident)
247            .field("diagnostics", &self.diagnostics)
248            .field("filter", &self.filter)
249            .field("selected", &self.selected)
250            .field("visible_rows", &self.visible_rows)
251            .field("disabled", &self.disabled)
252            .finish()
253    }
254}
255
256impl DiagnosticsList {
257    pub fn new(
258        ident: impl Into<Ident>,
259        diagnostics: Loadable<Vec<Diagnostic>, SharedString>,
260    ) -> Self {
261        Self {
262            ident: ident.into(),
263            diagnostics,
264            filter: DiagnosticFilter::default(),
265            selected: None,
266            visible_rows: None,
267            size: ControlSize::Md,
268            disabled: false,
269            on_filter: None,
270            on_select: None,
271            on_action: None,
272            on_retry: None,
273        }
274    }
275    pub fn filter(mut self, filter: DiagnosticFilter) -> Self {
276        self.filter = filter;
277        self
278    }
279    pub fn selected(mut self, id: impl Into<SharedString>) -> Self {
280        self.selected = Some(id.into());
281        self
282    }
283    pub fn visible_rows(mut self, rows: usize) -> Self {
284        self.visible_rows = Some(rows);
285        self
286    }
287    pub fn on_filter(
288        mut self,
289        handler: impl Fn(DiagnosticFilter, &mut Window, &mut App) + 'static,
290    ) -> Self {
291        self.on_filter = Some(Rc::new(handler));
292        self
293    }
294    pub fn on_select(
295        mut self,
296        handler: impl Fn(SharedString, &mut Window, &mut App) + 'static,
297    ) -> Self {
298        self.on_select = Some(Rc::new(handler));
299        self
300    }
301    pub fn on_action(
302        mut self,
303        handler: impl Fn(SharedString, SharedString, &mut Window, &mut App) + 'static,
304    ) -> Self {
305        self.on_action = Some(Rc::new(handler));
306        self
307    }
308    pub fn on_retry(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
309        self.on_retry = Some(Rc::new(handler));
310        self
311    }
312}
313
314impl Disableable for DiagnosticsList {
315    fn disabled(mut self, disabled: bool) -> Self {
316        self.disabled = disabled;
317        self
318    }
319}
320impl Sizable for DiagnosticsList {
321    fn control_size(mut self, size: ControlSize) -> Self {
322        self.size = size;
323        self
324    }
325}
326
327impl RenderOnce for DiagnosticsList {
328    fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
329        let ident = self.ident.clone();
330        let state = match self.diagnostics {
331            Loadable::Idle => EmptyState::new(
332                ident.child("idle"),
333                cx.strings().text(StringKey::DiagnosticsUnstarted),
334            )
335            .kind(EmptyKind::Unstarted)
336            .into_any_element(),
337            Loadable::Loading => PulseLoader::new(ident.child("loading"))
338                .label(cx.strings().text(StringKey::Loading))
339                .into_any_element(),
340            Loadable::Empty => EmptyState::new(
341                ident.child("empty"),
342                cx.strings().text(StringKey::DiagnosticsEmpty),
343            )
344            .kind(EmptyKind::Empty)
345            .into_any_element(),
346            Loadable::Unavailable(reason) => EmptyState::new(
347                ident.child("unavailable"),
348                cx.strings().text(StringKey::DiagnosticsUnavailable),
349            )
350            .kind(EmptyKind::Unavailable)
351            .detail(reason)
352            .into_any_element(),
353            Loadable::Error(reason) => {
354                let mut empty = EmptyState::new(
355                    ident.child("error"),
356                    cx.strings().text(StringKey::DiagnosticsError),
357                )
358                .kind(EmptyKind::Failed)
359                .detail(reason);
360                if let Some(handler) = self.on_retry.clone().filter(|_| !self.disabled) {
361                    empty = empty.action(
362                        Button::new(ident.child("retry"))
363                            .label(cx.strings().text(StringKey::TryAgain))
364                            .semantic_parent(ident.child("error").semantic_id())
365                            .control_size(self.size)
366                            .on_click(move |window, cx| handler(window, cx)),
367                    );
368                }
369                empty.into_any_element()
370            }
371            Loadable::Ready(diagnostics) if diagnostics.is_empty() => EmptyState::new(
372                ident.child("empty"),
373                cx.strings().text(StringKey::DiagnosticsEmpty),
374            )
375            .kind(EmptyKind::Empty)
376            .into_any_element(),
377            Loadable::Ready(diagnostics) => {
378                let rows: Vec<_> = diagnostics
379                    .into_iter()
380                    .filter(|diagnostic| self.filter.includes(diagnostic))
381                    .collect();
382                let filter_handler = self.on_filter.clone().filter(|_| !self.disabled);
383                let conditions = self.filter.severities().map(|severity| {
384                    FilterCondition::new(
385                        severity.name(),
386                        cx.strings().text(StringKey::DiagnosticsFilterField),
387                        cx.strings().text(StringKey::DiagnosticsFilterOperator),
388                        severity.label(cx),
389                    )
390                    .tone(severity.tone())
391                });
392                let filter_control = div()
393                    .flex()
394                    .flex_wrap()
395                    .gap(px(cx.theme().space(Space::Xs)))
396                    .children(DiagnosticSeverity::ALL.map(|severity| {
397                        let mut toggle =
398                            Toggle::new(ident.child("filters.severities").child(severity.name()))
399                                .label(severity.label(cx))
400                                .pressed(self.filter.contains(severity))
401                                .control_size(self.size)
402                                .disabled(self.disabled)
403                                .semantic_parent(ident.child("filters").semantic_id());
404                        if let Some(handler) = filter_handler.clone() {
405                            let filter = self.filter;
406                            toggle = toggle.on_press(move |included, window, cx| {
407                                handler(filter.setting(severity, included), window, cx);
408                            });
409                        }
410                        toggle
411                    }));
412                let mut bar = FilterBar::new(ident.child("filters"))
413                    .conditions(conditions)
414                    .add_control(filter_control)
415                    .count(ResultCount::Known(rows.len()))
416                    .control_size(self.size)
417                    .disabled(self.disabled);
418                if let Some(handler) = filter_handler.clone() {
419                    let filter = self.filter;
420                    bar = bar.on_remove(move |id, window, cx| {
421                        if let Some(severity) = DiagnosticSeverity::ALL
422                            .into_iter()
423                            .find(|severity| severity.name() == id.as_ref())
424                        {
425                            handler(filter.removing(severity), window, cx);
426                        }
427                    });
428                }
429                if let Some(handler) = filter_handler {
430                    let filter = self.filter;
431                    bar = bar.on_clear(move |window, cx| handler(filter.clearing(), window, cx));
432                }
433                if rows.is_empty() {
434                    div()
435                        .flex()
436                        .flex_col()
437                        .child(bar)
438                        .child(
439                            EmptyState::new(
440                                ident.child("no-match"),
441                                cx.strings().text(StringKey::DiagnosticsNoMatch),
442                            )
443                            .kind(EmptyKind::Empty),
444                        )
445                        .into_any_element()
446                } else {
447                    let rows = Rc::new(rows);
448                    let action_handler = self.on_action.clone().filter(|_| !self.disabled);
449                    let row_ident = ident.child("list");
450                    let size = self.size;
451                    let disabled = self.disabled;
452                    let rows_for_render = Rc::clone(&rows);
453                    let mut list = List::new(row_ident.clone(), rows.len(), move |index, _, cx| {
454                        let diagnostic = &rows_for_render[index];
455                        let diagnostic_id = diagnostic.id.clone();
456                        let item_ident = row_ident.child(diagnostic.id.as_ref());
457                        let actions = diagnostic.actions.iter().map(|action| {
458                            let mut button =
459                                Button::new(item_ident.child("action").child(action.id.as_ref()))
460                                    .label(action.label.clone())
461                                    .semantic_parent(item_ident.semantic_id())
462                                    .ghost()
463                                    .control_size(size)
464                                    .disabled(disabled || diagnostic.disabled || action.disabled);
465                            if let Some(handler) = action_handler
466                                .clone()
467                                .filter(|_| !diagnostic.disabled && !action.disabled)
468                            {
469                                let diagnostic_id = diagnostic_id.clone();
470                                let action_id = action.id.clone();
471                                button = button.on_click(move |window, cx| {
472                                    cx.stop_propagation();
473                                    handler(diagnostic_id.clone(), action_id.clone(), window, cx);
474                                });
475                            }
476                            button
477                        });
478                        let content = div()
479                            .flex()
480                            .items_center()
481                            .gap(px(cx.theme().space(Space::Sm)))
482                            .child(
483                                div()
484                                    .flex()
485                                    .flex_col()
486                                    .flex_1()
487                                    .child(
488                                        text(
489                                            cx.theme(),
490                                            TypeScale::Caption,
491                                            diagnostic.location.0.clone(),
492                                        )
493                                        .text_tone(cx.theme(), TextTone::Muted),
494                                    )
495                                    .child(text(
496                                        cx.theme(),
497                                        TypeScale::Body,
498                                        diagnostic.message.clone(),
499                                    )),
500                            )
501                            .child(
502                                div()
503                                    .flex_none()
504                                    .child(
505                                        Badge::new(diagnostic.severity.label(cx))
506                                            .tone(diagnostic.severity.tone()),
507                                    )
508                                    .semantic_in(
509                                        cx,
510                                        NodeSpec::new(
511                                            item_ident.child("severity").semantic_id(),
512                                            Role::Status,
513                                        )
514                                        .parent(item_ident.semantic_id())
515                                        .text(diagnostic.severity.label(cx)),
516                                    ),
517                            )
518                            .children(actions);
519                        ListItem::new(diagnostic.id.clone(), content)
520                            .text(diagnostic.message.clone())
521                            .disabled(disabled || diagnostic.disabled)
522                    })
523                    .row_height(cx.theme().control.get(self.size).height * 2.0)
524                    .control_size(self.size)
525                    .disabled(self.disabled);
526                    if let Some(selected) = self.selected {
527                        list = list.selected(selected);
528                    }
529                    if let Some(rows) = self.visible_rows {
530                        list = list.visible_rows(rows);
531                    }
532                    if let Some(handler) = self.on_select.filter(|_| !self.disabled) {
533                        list = list.on_select(move |id, window, cx| handler(id, window, cx));
534                    }
535                    div()
536                        .flex()
537                        .flex_col()
538                        .child(bar)
539                        .child(list)
540                        .into_any_element()
541                }
542            }
543        };
544        div()
545            .w_full()
546            .child(state)
547            .semantic_in(cx, NodeSpec::new(ident.semantic_id(), Role::Group))
548    }
549}