Skip to main content

gpui_kit/content/
log_stream.rs

1//! A fixed-row, virtualized presentation of caller-owned log entries.
2//!
3//! This component owns no process and reads no stream. The caller supplies
4//! every entry, its stable identity, already-formatted timestamp, level and
5//! source strings, and any search-hit ranges. Appending and filtering remain
6//! caller operations.
7//!
8//! # One entry, one fixed slot
9//!
10//! [`LogStream`] composes the crate's [`List`], so only the
11//! rows inside its bounded viewport are laid out. Every entry is one clipped,
12//! non-wrapping slot. That makes a large stream viewport-cheap, at the explicit
13//! cost that a long message does not grow its row. The list node publishes the
14//! total while only visible entry identities appear in the semantic tree.
15//!
16//! # Following is view state
17//!
18//! Following the newest entry, and pausing that follow, change only where the
19//! viewport is looking. They are transient visual state keyed by the stream's
20//! identity. Selecting an entry and asking to copy one are caller-owned intents
21//! and are only offered when the matching callbacks exist. GPUI has no pointer
22//! text-selection primitive, so this component does not claim one.
23
24use std::ops::Range;
25use std::rc::Rc;
26
27use gpui::{
28    AnyElement, App, InteractiveElement, IntoElement, ParentElement, RenderOnce, SharedString,
29    Styled, Window, div, px,
30};
31use gpui_kit_semantics::{NodeSpec, Role, Semantic};
32use gpui_kit_theme::{ActiveTheme, ControlSize, Elevation, Radius, Space, Surface, TypeScale};
33
34use crate::controls::button::Button;
35use crate::data::{List, ListItem, scroll_to_row};
36use crate::display::badge::{Badge, Tone};
37use crate::display::empty::{EmptyKind, EmptyState};
38use crate::display::highlight::HighlightedText;
39use crate::display::loading::PulseLoader;
40use crate::display::status::StatusDot;
41use crate::foundation::{Disableable, Ident, Sizable, StyledExt};
42use crate::motion::keyed;
43use crate::strings::{ActiveStrings, StringKey};
44
45type EntryHandler = Rc<dyn Fn(SharedString, &mut Window, &mut App)>;
46
47/// One caller-owned log entry.
48#[derive(Debug, Clone, PartialEq, Eq)]
49pub struct LogEntry {
50    pub id: SharedString,
51    pub timestamp: SharedString,
52    pub level: SharedString,
53    pub source: SharedString,
54    pub message: SharedString,
55    pub tone: Tone,
56    pub search_hits: Vec<Range<usize>>,
57    pub current_hit: Option<usize>,
58}
59
60impl LogEntry {
61    pub fn new(id: impl Into<SharedString>, message: impl Into<SharedString>) -> Self {
62        Self {
63            id: id.into(),
64            timestamp: SharedString::default(),
65            level: SharedString::default(),
66            source: SharedString::default(),
67            message: message.into(),
68            tone: Tone::Neutral,
69            search_hits: Vec::new(),
70            current_hit: None,
71        }
72    }
73
74    /// A timestamp the caller already formatted.
75    pub fn timestamp(mut self, timestamp: impl Into<SharedString>) -> Self {
76        self.timestamp = timestamp.into();
77        self
78    }
79
80    /// The caller's level name and the semantic tone it should carry.
81    pub fn level(mut self, level: impl Into<SharedString>, tone: Tone) -> Self {
82        self.level = level.into();
83        self.tone = tone;
84        self
85    }
86
87    /// The caller's source name. Nothing here interprets it.
88    pub fn source(mut self, source: impl Into<SharedString>) -> Self {
89        self.source = source.into();
90        self
91    }
92
93    /// Search hits as sorted, non-overlapping UTF-8 byte ranges into
94    /// [`LogEntry::message`]. Invalid ranges are skipped by `HighlightedText`.
95    pub fn search_hits(mut self, hits: impl IntoIterator<Item = Range<usize>>) -> Self {
96        self.search_hits = hits.into_iter().collect();
97        self
98    }
99
100    /// Which supplied search hit is current.
101    pub fn current_hit(mut self, hit: usize) -> Self {
102        self.current_hit = Some(hit);
103        self
104    }
105}
106
107/// What is known about the stream as a whole.
108#[derive(Debug, Clone, PartialEq, Eq, Default)]
109pub enum LogStreamState {
110    Loading,
111    Empty,
112    Unavailable(SharedString),
113    Error(SharedString),
114    /// Entries are the last verified value; the text says why they are stale.
115    Stale(SharedString),
116    #[default]
117    Ready,
118}
119
120impl LogStreamState {
121    pub fn name(&self) -> &'static str {
122        match self {
123            Self::Loading => "loading",
124            Self::Empty => "empty",
125            Self::Unavailable(_) => "unavailable",
126            Self::Error(_) => "error",
127            Self::Stale(_) => "stale",
128            Self::Ready => "ready",
129        }
130    }
131
132    fn shows_entries(&self) -> bool {
133        matches!(self, Self::Ready | Self::Stale(_))
134    }
135}
136
137/// A virtualized, read-only log presentation.
138#[derive(IntoElement)]
139pub struct LogStream {
140    ident: Ident,
141    entries: Vec<LogEntry>,
142    state: LogStreamState,
143    visible_rows: usize,
144    selected: Option<SharedString>,
145    on_select: Option<EntryHandler>,
146    on_copy: Option<EntryHandler>,
147}
148
149impl std::fmt::Debug for LogStream {
150    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
151        formatter
152            .debug_struct("LogStream")
153            .field("ident", &self.ident)
154            .field("entries", &self.entries.len())
155            .field("state", &self.state)
156            .field("visible_rows", &self.visible_rows)
157            .field("selected", &self.selected)
158            .finish()
159    }
160}
161
162impl LogStream {
163    pub fn new(ident: impl Into<Ident>, entries: impl IntoIterator<Item = LogEntry>) -> Self {
164        Self {
165            ident: ident.into(),
166            entries: entries.into_iter().collect(),
167            state: LogStreamState::Ready,
168            visible_rows: 12,
169            selected: None,
170            on_select: None,
171            on_copy: None,
172        }
173    }
174
175    pub fn state(mut self, state: LogStreamState) -> Self {
176        self.state = state;
177        self
178    }
179
180    /// Bounds and virtualizes the stream to this many fixed-height entries.
181    pub fn visible_rows(mut self, rows: usize) -> Self {
182        self.visible_rows = rows.max(1);
183        self
184    }
185
186    /// The caller-owned selected entry.
187    pub fn selected(mut self, id: impl Into<SharedString>) -> Self {
188        self.selected = Some(id.into());
189        self
190    }
191
192    /// Reports an entry selection and changes no data.
193    pub fn on_select(
194        mut self,
195        handler: impl Fn(SharedString, &mut Window, &mut App) + 'static,
196    ) -> Self {
197        self.on_select = Some(Rc::new(handler));
198        self
199    }
200
201    /// Reports that the selected entry should be copied. The host decides what
202    /// representation belongs on the clipboard.
203    pub fn on_copy(
204        mut self,
205        handler: impl Fn(SharedString, &mut Window, &mut App) + 'static,
206    ) -> Self {
207        self.on_copy = Some(Rc::new(handler));
208        self
209    }
210}
211
212#[derive(Debug, Default)]
213struct FollowState {
214    started: bool,
215    count: usize,
216    newest: Option<SharedString>,
217    paused: bool,
218}
219
220impl RenderOnce for LogStream {
221    fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
222        let theme = cx.theme().clone();
223        let entries = Rc::new(self.entries);
224        let count = entries.len();
225        let shows_entries = self.state.shows_entries();
226        let list_ident = self.ident.child("entries");
227        let follow =
228            keyed::slot::<FollowState>(&self.ident.child("follow-state").semantic_id(), cx);
229        let (paused, should_follow) = {
230            let mut state = follow.borrow_mut();
231            let newest = entries.last().map(|entry| entry.id.clone());
232            let should_follow = shows_entries
233                && count > 0
234                && !state.paused
235                && (!state.started || count != state.count || newest != state.newest);
236            if shows_entries {
237                state.started = true;
238                state.count = count;
239                state.newest = newest;
240            }
241            (state.paused, should_follow)
242        };
243        if should_follow {
244            scroll_to_row(&list_ident, count - 1, cx);
245        }
246
247        let body = if shows_entries {
248            entries_body(
249                Rc::clone(&entries),
250                &list_ident,
251                &theme,
252                self.visible_rows,
253                self.selected.clone(),
254                self.on_select.clone(),
255            )
256        } else {
257            state_body(&self.ident, &self.state, cx)
258        };
259
260        let toolbar = shows_entries.then(|| {
261            let last = count.saturating_sub(1);
262            let follow_label = cx.strings().text(if paused {
263                StringKey::LogFollow
264            } else {
265                StringKey::LogPause
266            });
267            let follow_cell = Rc::clone(&follow);
268            let scroll_ident = list_ident.clone();
269            let follow_button = Button::new(self.ident.child("follow"))
270                .label(follow_label)
271                .ghost()
272                .control_size(ControlSize::Xs)
273                .semantic_parent(self.ident.semantic_id())
274                .checked_state(!paused)
275                .on_click(move |window, cx| {
276                    let mut state = follow_cell.borrow_mut();
277                    state.paused = !state.paused;
278                    let resumes = !state.paused;
279                    drop(state);
280                    if resumes && count > 0 {
281                        scroll_to_row(&scroll_ident, last, cx);
282                    }
283                    window.refresh();
284                });
285
286            let copy_target = self
287                .selected
288                .as_ref()
289                .filter(|selected| entries.iter().any(|entry| &entry.id == *selected));
290            let copy_button = self.on_copy.as_ref().map(|handler| {
291                let mut button = Button::new(self.ident.child("copy"))
292                    .label(cx.strings().text(StringKey::Copy))
293                    .ghost()
294                    .control_size(ControlSize::Xs)
295                    .semantic_parent(self.ident.semantic_id())
296                    .disabled(copy_target.is_none());
297                if let Some(target) = copy_target.cloned() {
298                    let handler = Rc::clone(handler);
299                    button = button.on_click(move |window, cx| handler(target.clone(), window, cx));
300                }
301                button
302            });
303
304            let mode_label = cx.strings().text(if paused {
305                StringKey::LogPaused
306            } else {
307                StringKey::LogFollowing
308            });
309            div()
310                .row()
311                .w_full()
312                .items_center()
313                .justify_between()
314                .gap_token(&theme, Space::Sm)
315                .child(
316                    div()
317                        .row()
318                        .items_center()
319                        .gap_token(&theme, Space::Xs)
320                        .type_scale(&theme, TypeScale::Caption)
321                        .text_color(if paused {
322                            theme.colors.warning
323                        } else {
324                            theme.colors.text_muted
325                        })
326                        .child(StatusDot::new(if paused {
327                            Tone::Warning
328                        } else {
329                            Tone::Success
330                        }))
331                        .child(mode_label.clone())
332                        .semantic_in(
333                            cx,
334                            NodeSpec::new(self.ident.child("mode").semantic_id(), Role::Status)
335                                .parent(self.ident.semantic_id())
336                                .text(mode_label)
337                                .value(if paused { "paused" } else { "following" }),
338                        ),
339                )
340                .child(
341                    div()
342                        .row()
343                        .gap_token(&theme, Space::Xs)
344                        .children(copy_button)
345                        .child(follow_button),
346                )
347        });
348
349        let stale = match &self.state {
350            LogStreamState::Stale(reason) => Some(
351                div()
352                    .row()
353                    .items_center()
354                    .gap_token(&theme, Space::Xs)
355                    .type_scale(&theme, TypeScale::Caption)
356                    .text_color(theme.colors.warning)
357                    .child(StatusDot::new(Tone::Warning))
358                    .child(reason.clone())
359                    .semantic_in(
360                        cx,
361                        NodeSpec::new(self.ident.child("stale").semantic_id(), Role::Status)
362                            .parent(self.ident.semantic_id())
363                            .text(reason.clone())
364                            .value("stale"),
365                    ),
366            ),
367            _ => None,
368        };
369
370        let mut spec = NodeSpec::new(self.ident.semantic_id(), Role::Region)
371            .value(self.state.name())
372            .busy(matches!(self.state, LogStreamState::Loading))
373            .invalid(matches!(self.state, LogStreamState::Error(_)));
374        if let LogStreamState::Unavailable(reason) | LogStreamState::Error(reason) = &self.state {
375            spec = spec.description(reason.clone());
376        }
377
378        div()
379            .id(self.ident.element_id())
380            .column()
381            .w_full()
382            .gap_token(&theme, Space::Xs)
383            .p_token(&theme, Space::Sm)
384            .radius(&theme, Radius::Card)
385            .frame(&theme, Surface::Raised, Elevation::Raised)
386            .children(toolbar)
387            .children(stale)
388            .child(body)
389            .semantic_in(cx, spec)
390    }
391}
392
393fn entries_body(
394    entries: Rc<Vec<LogEntry>>,
395    list_ident: &Ident,
396    theme: &gpui_kit_theme::Theme,
397    visible_rows: usize,
398    selected: Option<SharedString>,
399    on_select: Option<EntryHandler>,
400) -> AnyElement {
401    let rows = Rc::clone(&entries);
402    let parent = list_ident.clone();
403    let row_theme = theme.clone();
404    let mut list = List::new(list_ident.clone(), entries.len(), move |index, _, cx| {
405        let entry = &rows[index];
406        ListItem::new(entry.id.clone(), entry_row(&parent, entry, &row_theme, cx))
407            // Log payloads stay out of diagnostic snapshots. The caller's
408            // level is enough to name the row without publishing the message.
409            .text(entry.level.clone())
410    })
411    .row_height(theme.control.get(ControlSize::Sm).height)
412    .visible_rows(visible_rows);
413    if let Some(selected) = selected {
414        list = list.selected(selected);
415    }
416    if let Some(handler) = on_select {
417        list = list.on_select(move |id, window, cx| handler(id, window, cx));
418    }
419    list.into_any_element()
420}
421
422fn entry_row(
423    parent: &Ident,
424    entry: &LogEntry,
425    theme: &gpui_kit_theme::Theme,
426    cx: &App,
427) -> AnyElement {
428    let entry_ident = parent.child(entry.id.as_ref());
429    let mut message = HighlightedText::new(entry.message.clone())
430        .hits(entry.search_hits.clone())
431        .monospace(true);
432    if let Some(current) = entry.current_hit {
433        message = message.current(current);
434    }
435    let drawn = message.published_hits();
436    let message = div()
437        .flex_1()
438        .min_w_0()
439        .h_full()
440        .overflow_hidden()
441        .whitespace_nowrap()
442        .child(message);
443    let message = if drawn > 0 {
444        message
445            .semantic_in(
446                cx,
447                NodeSpec::new(entry_ident.child("hits").semantic_id(), Role::Status)
448                    .parent(entry_ident.semantic_id())
449                    .value(drawn.to_string()),
450            )
451            .into_any_element()
452    } else {
453        message.into_any_element()
454    };
455
456    div()
457        .row()
458        .items_center()
459        .w_full()
460        .h_full()
461        .gap_token(theme, Space::Sm)
462        .font_family(theme.typography.mono.clone())
463        .text_size(px(theme.typography.code.size))
464        .line_height(px(theme.typography.code.line_height))
465        .child(
466            div()
467                .flex_none()
468                .w(px(76.0))
469                .overflow_hidden()
470                .text_color(theme.colors.text_faint)
471                .child(entry.timestamp.clone()),
472        )
473        .child(
474            div()
475                .flex_none()
476                .w(px(68.0))
477                .overflow_hidden()
478                .child(Badge::new(entry.level.clone()).tone(entry.tone)),
479        )
480        .child(
481            div()
482                .flex_none()
483                .w(px(104.0))
484                .overflow_hidden()
485                .text_color(theme.colors.text_muted)
486                .child(entry.source.clone()),
487        )
488        .child(message)
489        .into_any_element()
490}
491
492fn state_body(ident: &Ident, state: &LogStreamState, cx: &App) -> AnyElement {
493    let strings = cx.strings();
494    match state {
495        LogStreamState::Loading => div()
496            .flex()
497            .items_center()
498            .justify_center()
499            .w_full()
500            .p(px(24.0))
501            .child(PulseLoader::new(ident.child("loading")).label(strings.text(StringKey::Loading)))
502            .into_any_element(),
503        LogStreamState::Empty => {
504            EmptyState::new(ident.child("empty"), strings.text(StringKey::LogEmpty))
505                .kind(EmptyKind::Empty)
506                .into_any_element()
507        }
508        LogStreamState::Unavailable(reason) => EmptyState::new(
509            ident.child("unavailable"),
510            strings.text(StringKey::LogUnavailable),
511        )
512        .kind(EmptyKind::Unavailable)
513        .detail(reason.clone())
514        .into_any_element(),
515        LogStreamState::Error(reason) => {
516            EmptyState::new(ident.child("error"), strings.text(StringKey::LogError))
517                .kind(EmptyKind::Failed)
518                .detail(reason.clone())
519                .into_any_element()
520        }
521        LogStreamState::Ready | LogStreamState::Stale(_) => div().into_any_element(),
522    }
523}