1use 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#[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
142pub enum Dock {
143 #[default]
144 Right,
145 Bottom,
146 Detached,
148}
149
150#[derive(Debug, Clone)]
153pub enum DevToolsEvent {
154 Close,
156 Dock(Dock),
158 RevealSource(SourceRef),
161 Picking(bool),
163}
164
165pub struct DevTools {
167 focus: FocusHandle,
168 tab: DevToolsTab,
169 dock: Dock,
170 tree: Tree,
173 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 pub fn tab(mut self, tab: DevToolsTab) -> Self {
252 self.tab = tab;
253 self
254 }
255
256 pub fn dock(mut self, dock: Dock) -> Self {
258 self.dock = dock;
259 self
260 }
261
262 pub fn elements_sidebar(mut self, sidebar: ElementsSidebar) -> Self {
264 self.elements.sidebar = sidebar;
265 self
266 }
267
268 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 pub fn is_picking(&self) -> bool {
285 self.picking
286 }
287
288 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 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 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 pub fn tree(&self) -> &ProbeTree {
322 &self.tree
323 }
324
325 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 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 .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 cx.notify();
574 }
575
576 probe::begin_frame(window);
580 self.tree = probe::tree();
581
582 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 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 if self.recorder_active {
652 probe::release();
653 }
654 }
655}
656
657#[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
671pub 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
679pub 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
688pub 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
695pub 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
702pub 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
709pub 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
716pub 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
728pub fn clear(cx: &mut App) {
730 if cx.has_global::<DevToolsState>() {
731 cx.update_global::<DevToolsState, _>(|state, _cx| state.clear_all());
732 }
733}
734
735pub 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}