1use 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#[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 pub fn timestamp(mut self, timestamp: impl Into<SharedString>) -> Self {
76 self.timestamp = timestamp.into();
77 self
78 }
79
80 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 pub fn source(mut self, source: impl Into<SharedString>) -> Self {
89 self.source = source.into();
90 self
91 }
92
93 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 pub fn current_hit(mut self, hit: usize) -> Self {
102 self.current_hit = Some(hit);
103 self
104 }
105}
106
107#[derive(Debug, Clone, PartialEq, Eq, Default)]
109pub enum LogStreamState {
110 Loading,
111 Empty,
112 Unavailable(SharedString),
113 Error(SharedString),
114 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#[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 pub fn visible_rows(mut self, rows: usize) -> Self {
182 self.visible_rows = rows.max(1);
183 self
184 }
185
186 pub fn selected(mut self, id: impl Into<SharedString>) -> Self {
188 self.selected = Some(id.into());
189 self
190 }
191
192 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 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 .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}