Skip to main content

guise/devtools/
mod.rs

1//! `DevTools` — Safari's Web Inspector, for a gpui app.
2//!
3//! The Web Inspector's layout is doing real work: a tab bar of *tools* rather
4//! than a stack of panels, a tree beside a details sidebar, a console that
5//! drops down over whatever else you were looking at. This module reproduces
6//! that — Elements, Network, Sources, Timelines, Storage, Layers, Logs and
7//! Audit — against a native app instead of a page.
8//!
9//! Where the data comes from differs by panel, and that difference is the whole
10//! design:
11//!
12//! * **Elements**, **Layers** and the Styles sidebar are *real introspection*.
13//!   Components tag their root with [`Probed::probe`], so the tree, the
14//!   bounds, and the style declarations are read back out of what actually
15//!   rendered this frame.
16//! * **Logs**, **Network**, **Storage** and **Timelines** are *reported*.
17//!   `guise` never opens a socket, so the host calls [`log`], [`network_begin`],
18//!   [`storage_set`] and friends, exactly as [`crate::ai`] displays a
19//!   conversation it does not conduct.
20//!
21//!   Safari's equivalent tab is the Console, and it is called Logs here for a
22//!   reason: half of that tab is a JavaScript evaluator, and a compiled binary
23//!   has nothing to evaluate. A prompt that cannot run anything is worse than
24//!   no prompt, so the panel is named for the half that does transfer.
25//! * **Audit** is computed here, from the tree, against the library's own
26//!   conventions.
27//!
28//! ```ignore
29//! // once at startup
30//! DevToolsState::new().init(cx);
31//!
32//! // wherever the inspector should live — a pane, a drawer, its own window
33//! let devtools = cx.new(|cx| DevTools::new(cx));
34//!
35//! // and from anywhere in the app
36//! guise::devtools::log(cx, LogLevel::Warning, "cache miss");
37//! ```
38//!
39//! Nothing here is compiled out of release builds, because dead-code
40//! elimination already handles that: an app that never constructs [`DevTools`]
41//! links none of it. What an app *does* pay for is the [`Probed::probe`] calls
42//! left in components, and those are a single boolean check while the
43//! inspector is closed.
44
45use std::time::Duration;
46
47mod audit;
48mod elements;
49mod logs;
50mod network;
51mod probe;
52mod shell;
53mod sources;
54mod state;
55mod storage;
56mod styles;
57mod timelines;
58
59pub use elements::ElementsSidebar;
60pub use probe::{
61  begin_frame, set_enabled, tree, with_tree, Probe, ProbeNode, ProbeTree, Probed, ProbedAny,
62};
63pub use state::*;
64pub use styles::{box_model, declarations, hex, BoxModel, Declaration};
65
66use gpui::prelude::*;
67use gpui::{
68  div, px, App, Context, EventEmitter, FocusHandle, Focusable, SharedString, Task, Window,
69};
70
71use audit::AuditPanel;
72use elements::ElementsPanel;
73use logs::LogsPanel;
74use network::NetworkPanel;
75use probe::ProbeTree as Tree;
76use shell::{glyph, hairline, tool_button, Ink, BAR_HEIGHT, LABEL_SIZE};
77use sources::SourcesPanel;
78use storage::StoragePanel;
79use timelines::TimelinesPanel;
80
81use crate::icon::IconName;
82
83const RECORDER_IDLE_AFTER: Duration = Duration::from_millis(250);
84
85/// The tools along the top, in Safari's order.
86#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
87pub enum DevToolsTab {
88  #[default]
89  Elements,
90  Network,
91  Sources,
92  Timelines,
93  Storage,
94  Layers,
95  Logs,
96  Audit,
97}
98
99impl DevToolsTab {
100  pub fn label(self) -> &'static str {
101    match self {
102      DevToolsTab::Elements => "Elements",
103      DevToolsTab::Network => "Network",
104      DevToolsTab::Sources => "Sources",
105      DevToolsTab::Timelines => "Timelines",
106      DevToolsTab::Storage => "Storage",
107      DevToolsTab::Layers => "Layers",
108      DevToolsTab::Logs => "Logs",
109      DevToolsTab::Audit => "Audit",
110    }
111  }
112
113  fn icon(self) -> IconName {
114    match self {
115      DevToolsTab::Elements => IconName::Code,
116      DevToolsTab::Network => IconName::Network,
117      DevToolsTab::Sources => IconName::FileCode,
118      DevToolsTab::Timelines => IconName::Activity,
119      DevToolsTab::Storage => IconName::Database,
120      DevToolsTab::Layers => IconName::Layers,
121      DevToolsTab::Logs => IconName::ScrollText,
122      DevToolsTab::Audit => IconName::ShieldCheck,
123    }
124  }
125
126  pub const ALL: [DevToolsTab; 8] = [
127    DevToolsTab::Elements,
128    DevToolsTab::Network,
129    DevToolsTab::Sources,
130    DevToolsTab::Timelines,
131    DevToolsTab::Storage,
132    DevToolsTab::Layers,
133    DevToolsTab::Logs,
134    DevToolsTab::Audit,
135  ];
136}
137
138/// Where the host has put the inspector. `guise` cannot move a panel it does
139/// not own, so this only sets which dock button reads as pressed — the host
140/// acts on [`DevToolsEvent::Dock`].
141#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
142pub enum Dock {
143  #[default]
144  Right,
145  Bottom,
146  /// Its own window.
147  Detached,
148}
149
150/// What the inspector asks the host to do. Everything it can do alone, it does
151/// alone; these are the things that need the host.
152#[derive(Debug, Clone)]
153pub enum DevToolsEvent {
154  /// The close button was pressed.
155  Close,
156  /// A dock button was pressed.
157  Dock(Dock),
158  /// A source location was clicked. The host opens it in its editor; the
159  /// Sources panel has already switched to it if it knows the file.
160  RevealSource(SourceRef),
161  /// The element picker was armed or disarmed.
162  Picking(bool),
163}
164
165/// The inspector. Create with `cx.new(|cx| DevTools::new(cx))`.
166pub struct DevTools {
167  focus: FocusHandle,
168  tab: DevToolsTab,
169  dock: Dock,
170  /// The tree recorded by the previous frame, refreshed at the top of every
171  /// render.
172  tree: Tree,
173  /// The logs drawer, which Safari drops over any non-Logs tool.
174  drawer_open: bool,
175  picking: bool,
176  recorder_active: bool,
177  recorder_heartbeat: u64,
178  recorder_watchdog: Option<Task<()>>,
179  pub(crate) elements: ElementsPanel,
180  pub(crate) logs: LogsPanel,
181  pub(crate) network: NetworkPanel,
182  pub(crate) storage: StoragePanel,
183  pub(crate) timelines: TimelinesPanel,
184  pub(crate) sources: SourcesPanel,
185  pub(crate) audit: AuditPanel,
186}
187
188impl EventEmitter<DevToolsEvent> for DevTools {}
189
190impl Focusable for DevTools {
191  fn focus_handle(&self, _cx: &App) -> FocusHandle {
192    self.focus.clone()
193  }
194}
195
196impl DevTools {
197  pub fn new(cx: &mut Context<Self>) -> Self {
198    let mut inspector = DevTools {
199      focus: cx.focus_handle(),
200      tab: DevToolsTab::default(),
201      dock: Dock::default(),
202      tree: Tree::default(),
203      drawer_open: false,
204      picking: false,
205      recorder_active: false,
206      recorder_heartbeat: 0,
207      recorder_watchdog: None,
208      elements: ElementsPanel::default(),
209      logs: LogsPanel::new(cx),
210      network: NetworkPanel::new(cx),
211      storage: StoragePanel::default(),
212      timelines: TimelinesPanel::default(),
213      sources: SourcesPanel::default(),
214      audit: AuditPanel::default(),
215    };
216    inspector.activate_recorder(cx);
217    inspector
218  }
219
220  fn activate_recorder(&mut self, cx: &mut Context<Self>) {
221    self.recorder_heartbeat = self.recorder_heartbeat.wrapping_add(1);
222    if self.recorder_active {
223      return;
224    }
225
226    probe::retain();
227    self.recorder_active = true;
228    let mut last_heartbeat = self.recorder_heartbeat;
229    self.recorder_watchdog = Some(cx.spawn(async move |this, cx| loop {
230      cx.background_executor().timer(RECORDER_IDLE_AFTER).await;
231      let stop = this
232        .update(cx, |inspector, _cx| {
233          if inspector.recorder_heartbeat != last_heartbeat {
234            last_heartbeat = inspector.recorder_heartbeat;
235            return false;
236          }
237          if inspector.recorder_active {
238            probe::release();
239            inspector.recorder_active = false;
240          }
241          true
242        })
243        .unwrap_or(true);
244      if stop {
245        break;
246      }
247    }));
248  }
249
250  /// Open on a particular tool.
251  pub fn tab(mut self, tab: DevToolsTab) -> Self {
252    self.tab = tab;
253    self
254  }
255
256  /// Set which dock button reads as pressed.
257  pub fn dock(mut self, dock: Dock) -> Self {
258    self.dock = dock;
259    self
260  }
261
262  /// Open the Elements sidebar on a particular pane.
263  pub fn elements_sidebar(mut self, sidebar: ElementsSidebar) -> Self {
264    self.elements.sidebar = sidebar;
265    self
266  }
267
268  /// Start with the logs drawer down.
269  pub fn drawer(mut self, open: bool) -> Self {
270    self.drawer_open = open;
271    self
272  }
273
274  pub fn active_tab(&self) -> DevToolsTab {
275    self.tab
276  }
277
278  pub fn set_tab(&mut self, tab: DevToolsTab, cx: &mut Context<Self>) {
279    self.tab = tab;
280    cx.notify();
281  }
282
283  /// Whether the element picker is armed.
284  pub fn is_picking(&self) -> bool {
285    self.picking
286  }
287
288  /// Arm or disarm the picker. The host is told, because the actual hit
289  /// testing happens in the window, not here.
290  pub fn set_picking(&mut self, picking: bool, cx: &mut Context<Self>) {
291    self.picking = picking;
292    cx.emit(DevToolsEvent::Picking(picking));
293    cx.notify();
294  }
295
296  /// Select the element at a window point — what a host wires its picker to.
297  /// Returns whether anything was under it.
298  pub fn pick_at(&mut self, point: gpui::Point<gpui::Pixels>, cx: &mut Context<Self>) -> bool {
299    let Some(index) = self.tree.hit(point) else {
300      return false;
301    };
302    let key = self.tree.nodes[index].key.clone();
303    self.elements.reveal(&self.tree, &key);
304    self.tab = DevToolsTab::Elements;
305    self.picking = false;
306    cx.emit(DevToolsEvent::Picking(false));
307    cx.notify();
308    true
309  }
310
311  /// The bounds of the selected element, for a host that paints a highlight
312  /// over its own window.
313  pub fn selected_bounds(&self) -> Option<gpui::Bounds<gpui::Pixels>> {
314    self
315      .elements
316      .selected_node(&self.tree)
317      .map(|node| node.bounds)
318  }
319
320  /// The tree as of the last rendered frame.
321  pub fn tree(&self) -> &ProbeTree {
322    &self.tree
323  }
324
325  /// Switch to Sources on `source`, and tell the host in case it would rather
326  /// open the file in a real editor.
327  pub fn reveal_source(&mut self, source: SourceRef, cx: &mut Context<Self>) {
328    self.sources.reveal(source.clone());
329    self.tab = DevToolsTab::Sources;
330    cx.emit(DevToolsEvent::RevealSource(source));
331    cx.notify();
332  }
333
334  /// Toggle the logs drawer.
335  pub fn toggle_drawer(&mut self, cx: &mut Context<Self>) {
336    self.drawer_open = !self.drawer_open;
337    cx.notify();
338  }
339
340  fn toolbar(&self, ink: &Ink, cx: &mut Context<Self>) -> gpui::Div {
341    let (warnings, errors) = cx
342      .try_global::<DevToolsState>()
343      .map(|state| state.log_issues())
344      .unwrap_or((0, 0));
345    let (requests, transfer, _) = cx
346      .try_global::<DevToolsState>()
347      .map(|state| state.network_totals())
348      .unwrap_or((0, 0, 0));
349
350    let badge = |count: usize, icon: IconName, color: gpui::Hsla, cx: &App| {
351      div()
352        .flex()
353        .items_center()
354        .gap(px(3.0))
355        .child(glyph(icon, 11.0, color, cx))
356        .child(
357          div()
358            .text_color(if count > 0 { ink.text } else { ink.dim })
359            .child(SharedString::from(count.to_string())),
360        )
361    };
362
363    div()
364      .flex()
365      .flex_none()
366      .items_center()
367      .gap(px(2.0))
368      .h(px(BAR_HEIGHT))
369      .w_full()
370      .px(px(6.0))
371      .bg(ink.chrome)
372      .border_b_1()
373      .border_color(ink.border)
374      .child(
375        tool_button(
376          "devtools-dock-right",
377          IconName::PanelRight,
378          "Dock to right",
379          self.dock == Dock::Right,
380          ink,
381          cx,
382        )
383        .on_click(cx.listener(|this, _event, _window, cx| {
384          this.dock = Dock::Right;
385          cx.emit(DevToolsEvent::Dock(Dock::Right));
386          cx.notify();
387        })),
388      )
389      .child(
390        tool_button(
391          "devtools-dock-bottom",
392          IconName::PanelBottom,
393          "Dock to bottom",
394          self.dock == Dock::Bottom,
395          ink,
396          cx,
397        )
398        .on_click(cx.listener(|this, _event, _window, cx| {
399          this.dock = Dock::Bottom;
400          cx.emit(DevToolsEvent::Dock(Dock::Bottom));
401          cx.notify();
402        })),
403      )
404      .child(div().w(px(6.0)))
405      .child(
406        tool_button(
407          "devtools-pick",
408          IconName::Crosshair,
409          "Select an element",
410          self.picking,
411          ink,
412          cx,
413        )
414        .on_click(cx.listener(|this, _event, _window, cx| {
415          let picking = !this.picking;
416          this.set_picking(picking, cx);
417        })),
418      )
419      .child(
420        tool_button(
421          "devtools-drawer",
422          IconName::ScrollText,
423          "Show logs drawer",
424          self.drawer_open,
425          ink,
426          cx,
427        )
428        .on_click(cx.listener(|this, _event, _window, cx| this.toggle_drawer(cx))),
429      )
430      // The activity viewer: what the page cost, in Safari's words.
431      .child(
432        div()
433          .flex()
434          .flex_1()
435          .items_center()
436          .justify_center()
437          .gap(px(10.0))
438          .mx(px(8.0))
439          .h(px(19.0))
440          .rounded(px(4.0))
441          .bg(ink.content)
442          .border_1()
443          .border_color(ink.border)
444          .text_size(px(LABEL_SIZE))
445          .text_color(ink.dim)
446          .child(
447            div()
448              .flex()
449              .items_center()
450              .gap(px(3.0))
451              .child(glyph(IconName::Boxes, 11.0, ink.dim, cx))
452              .child(SharedString::from(format!("{} elements", self.tree.len()))),
453          )
454          .child(
455            div()
456              .flex()
457              .items_center()
458              .gap(px(3.0))
459              .child(glyph(IconName::ArrowUpDown, 11.0, ink.dim, cx))
460              .child(SharedString::from(format!(
461                "{requests} requests · {}",
462                format_bytes(transfer)
463              ))),
464          )
465          .child(badge(warnings, IconName::TriangleAlert, ink.warning, cx))
466          .child(badge(errors, IconName::CircleX, ink.danger, cx)),
467      )
468      .child(
469        tool_button(
470          "devtools-clear",
471          IconName::Ban,
472          "Clear all records",
473          false,
474          ink,
475          cx,
476        )
477        .on_click(cx.listener(|_this, _event, _window, cx| {
478          if cx.has_global::<DevToolsState>() {
479            cx.update_global::<DevToolsState, _>(|state, _cx| state.clear_all());
480          }
481          cx.notify();
482        })),
483      )
484      .child(
485        tool_button("devtools-close", IconName::X, "Close", false, ink, cx)
486          .on_click(cx.listener(|_this, _event, _window, cx| cx.emit(DevToolsEvent::Close))),
487      )
488  }
489
490  fn tab_bar(&self, ink: &Ink, cx: &mut Context<Self>) -> gpui::Div {
491    let mut bar = div()
492      .flex()
493      .flex_none()
494      .items_center()
495      .gap(px(1.0))
496      .h(px(BAR_HEIGHT))
497      .w_full()
498      .px(px(4.0))
499      .bg(ink.chrome)
500      .border_b_1()
501      .border_color(ink.border);
502
503    for tab in DevToolsTab::ALL {
504      let active = self.tab == tab;
505      let fg = if active { ink.text } else { ink.dim };
506      let hover_bg = ink.hover;
507      bar = bar.child(
508        div()
509          .id(("devtools-tab", tab as usize))
510          .flex()
511          .flex_none()
512          .items_center()
513          .gap(px(4.0))
514          .h(px(22.0))
515          .px(px(8.0))
516          .rounded(px(4.0))
517          .text_size(px(LABEL_SIZE))
518          .text_color(fg)
519          .when(active, |el| el.bg(ink.chrome_active))
520          .when(!active, |el| el.hover(move |st| st.bg(hover_bg)))
521          .child(glyph(tab.icon(), 12.0, fg, cx))
522          .child(SharedString::new_static(tab.label()))
523          .on_click(cx.listener(move |this, _event, _window, cx| {
524            this.tab = tab;
525            cx.notify();
526          })),
527      );
528    }
529
530    bar
531  }
532
533  fn status_bar(&self, ink: &Ink, cx: &mut Context<Self>) -> gpui::Div {
534    let fps = self
535      .timelines
536      .recording
537      .then(|| {
538        cx.try_global::<DevToolsState>()
539          .and_then(|state| state.fps())
540      })
541      .flatten();
542
543    div()
544      .flex()
545      .flex_none()
546      .items_center()
547      .gap(px(10.0))
548      .h(px(20.0))
549      .w_full()
550      .px(px(8.0))
551      .bg(ink.chrome)
552      .border_t_1()
553      .border_color(ink.border)
554      .text_size(px(LABEL_SIZE))
555      .text_color(ink.dim)
556      .child(SharedString::new_static("guise devtools"))
557      .child(div().flex_1())
558      .when_some(fps, |el, fps| {
559        el.child(SharedString::from(format!("{fps:.0} fps")))
560      })
561      .child(SharedString::from(self.tab.label()))
562  }
563}
564
565impl Render for DevTools {
566  fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
567    let was_idle = !self.recorder_active;
568    self.activate_recorder(cx);
569    if was_idle {
570      // The first frame turns probes back on; the follow-up captures the
571      // complete tree so reopening an inspector never needs a second
572      // user interaction.
573      cx.notify();
574    }
575
576    // Rotate the recorder before anything prepaints this frame, then read
577    // the tree the previous frame finished. Doing it here — rather than in
578    // the Elements panel — keeps every panel on the same snapshot.
579    probe::begin_frame(window);
580    self.tree = probe::tree();
581
582    // Open on something rather than on "no element selected", the way the
583    // Web Inspector starts on `<body>`. Only ever fills an empty
584    // selection, so it cannot fight the user.
585    if self.elements.selected.is_none() {
586      if let Some(root) = self.tree.roots.first() {
587        let key = self.tree.nodes[*root].key.clone();
588        self.elements.select(key);
589      }
590    }
591    if self.timelines.recording && cx.has_global::<DevToolsState>() {
592      cx.update_global::<DevToolsState, _>(|state, _cx| state.record_frame());
593    }
594
595    let ink = Ink::read(cx);
596    let body = match self.tab {
597      DevToolsTab::Elements => self.elements.render(&self.tree, window, cx),
598      DevToolsTab::Network => self.network.render(window, cx),
599      DevToolsTab::Sources => self.sources.render(&self.tree, window, cx),
600      DevToolsTab::Timelines => self.timelines.render(window, cx),
601      DevToolsTab::Storage => self.storage.render(window, cx),
602      DevToolsTab::Layers => {
603        elements::layers_view(&self.tree, self.elements.selected.as_ref(), &ink, cx)
604      }
605      DevToolsTab::Logs => self.logs.render(window, cx),
606      DevToolsTab::Audit => self.audit.render(&self.tree, window, cx),
607    };
608
609    // The drawer is the log shown *under* another tool, so it never appears
610    // while Logs is the tool.
611    let drawer = (self.drawer_open && self.tab != DevToolsTab::Logs).then(|| {
612      div()
613        .flex()
614        .flex_col()
615        .flex_none()
616        .h(px(200.0))
617        .w_full()
618        .child(hairline(&ink))
619        .child(self.logs.render(window, cx))
620    });
621
622    div()
623      .track_focus(&self.focus)
624      .key_context("DevTools")
625      .flex()
626      .flex_col()
627      .size_full()
628      .min_h(px(0.0))
629      .bg(ink.content)
630      .text_color(ink.text)
631      .child(self.toolbar(&ink, cx))
632      .child(self.tab_bar(&ink, cx))
633      .child(
634        div()
635          .flex()
636          .flex_col()
637          .flex_1()
638          .min_h(px(0.0))
639          .w_full()
640          .child(body),
641      )
642      .children(drawer)
643      .child(self.status_bar(&ink, cx))
644  }
645}
646
647impl Drop for DevTools {
648  fn drop(&mut self) {
649    // The last inspector to close stops the recording, and with it the
650    // per-frame cost every probe in the app would otherwise keep paying.
651    if self.recorder_active {
652      probe::release();
653    }
654  }
655}
656
657// --- the host-facing feed ---------------------------------------------------
658//
659// Every one of these is a no-op when `DevToolsState` was never installed, so
660// instrumentation can be left in place unconditionally. They are
661// `#[track_caller]` so the record knows where it came from without the caller
662// passing a location or reaching for a macro.
663
664/// Append a log line, stamped with the source location it was called from.
665#[track_caller]
666pub fn log(cx: &mut App, level: LogLevel, message: impl Into<SharedString>) {
667  let source = SourceRef::from(std::panic::Location::caller());
668  log_record(cx, LogRecord::new(level, message).source(source));
669}
670
671/// Append a fully-built log record — the form to use when the line has
672/// expandable detail rows.
673pub fn log_record(cx: &mut App, record: LogRecord) {
674  if cx.has_global::<DevToolsState>() {
675    cx.update_global::<DevToolsState, _>(|state, _cx| state.push_log(record));
676  }
677}
678
679/// Record a request that has just started. The returned id settles it later;
680/// `None` means the inspector is not installed and nothing was recorded.
681pub fn network_begin(cx: &mut App, record: NetworkRecord) -> Option<u64> {
682  if !cx.has_global::<DevToolsState>() {
683    return None;
684  }
685  Some(cx.update_global::<DevToolsState, _>(|state, _cx| state.push_network(record)))
686}
687
688/// Amend a request in flight.
689pub fn network_update(cx: &mut App, id: u64, f: impl FnOnce(&mut NetworkRecord)) {
690  if cx.has_global::<DevToolsState>() {
691    cx.update_global::<DevToolsState, _>(|state, _cx| state.update_network(id, f));
692  }
693}
694
695/// Publish a storage domain, replacing any previous snapshot of it.
696pub fn storage_set(cx: &mut App, domain: StorageDomain) {
697  if cx.has_global::<DevToolsState>() {
698    cx.update_global::<DevToolsState, _>(|state, _cx| state.set_storage(domain));
699  }
700}
701
702/// Remove a storage domain.
703pub fn storage_remove(cx: &mut App, id: &str) {
704  if cx.has_global::<DevToolsState>() {
705    cx.update_global::<DevToolsState, _>(|state, _cx| state.remove_storage(id));
706  }
707}
708
709/// Record a span on a timeline band.
710pub fn timeline_event(cx: &mut App, event: TimelineEvent) {
711  if cx.has_global::<DevToolsState>() {
712    cx.update_global::<DevToolsState, _>(|state, _cx| state.push_timeline(event));
713  }
714}
715
716/// Time `f` and record it as a Script span. The return value is passed through,
717/// so this wraps an existing call without restructuring it.
718pub fn measure<R>(cx: &mut App, label: impl Into<SharedString>, f: impl FnOnce() -> R) -> R {
719  let start = std::time::Instant::now();
720  let result = f();
721  timeline_event(
722    cx,
723    TimelineEvent::new(TimelineKind::Script, label, start.elapsed()),
724  );
725  result
726}
727
728/// Drop every record.
729pub fn clear(cx: &mut App) {
730  if cx.has_global::<DevToolsState>() {
731    cx.update_global::<DevToolsState, _>(|state, _cx| state.clear_all());
732  }
733}
734
735/// Whether the element recorder is running, for a host that wants to skip its
736/// own instrumentation while the inspector is closed.
737pub fn is_recording() -> bool {
738  probe::is_enabled()
739}
740
741#[cfg(test)]
742mod tests {
743  use std::time::Duration;
744
745  use gpui::{AppContext, TestAppContext};
746
747  use super::{probe, DevTools, RECORDER_IDLE_AFTER};
748
749  #[gpui::test]
750  fn recorder_stops_after_the_inspector_stops_rendering(cx: &mut TestAppContext) {
751    probe::set_enabled(false);
752    let inspector = cx.update(|cx| cx.new(DevTools::new));
753    assert!(probe::is_enabled());
754
755    cx.executor()
756      .advance_clock(RECORDER_IDLE_AFTER - Duration::from_millis(1));
757    inspector.update(cx, |inspector, cx| inspector.activate_recorder(cx));
758    cx.executor().advance_clock(Duration::from_millis(1));
759    cx.run_until_parked();
760    assert!(probe::is_enabled(), "a recent render should keep recording");
761
762    cx.executor().advance_clock(RECORDER_IDLE_AFTER);
763    cx.run_until_parked();
764    assert!(
765      !probe::is_enabled(),
766      "an idle inspector should release its tree"
767    );
768  }
769}