Skip to main content

gpui_rhai/
devtools.rs

1use std::collections::{BTreeMap, VecDeque};
2
3#[cfg(feature = "dev-reload")]
4use gpui::{
5    AnyElement, InteractiveElement, IntoElement, ParentElement, StatefulInteractiveElement, Styled,
6    deferred, div, px, rgba,
7};
8
9use crate::{
10    ComponentRegistry, ExecutionTiming, PrimitiveValue, SourceLocation, StateInstanceSnapshot,
11    StoreSnapshot, StyleProperties, ThemeVariant, UiNode, UiNodeKind, UiRuntimeState, UiValue,
12};
13
14#[derive(Clone, Copy, Debug, Eq, PartialEq)]
15pub enum RuntimeTraceKind {
16    Reconcile,
17    Event,
18    Action,
19    Capability,
20    Task,
21    Subscription,
22    State,
23    Store,
24    Signal,
25    Theme,
26    Locale,
27    Reload,
28    Window,
29}
30
31#[derive(Clone, Debug, PartialEq)]
32pub struct RuntimeTrace {
33    pub sequence: u64,
34    pub kind: RuntimeTraceKind,
35    pub scope: String,
36    pub message: String,
37    pub payload: Option<UiValue>,
38    pub sensitive: bool,
39}
40
41#[derive(Clone, Debug)]
42pub struct TraceBuffer {
43    capacity: usize,
44    next_sequence: u64,
45    traces: VecDeque<RuntimeTrace>,
46}
47
48impl Default for TraceBuffer {
49    fn default() -> Self {
50        Self::new(200)
51    }
52}
53
54impl TraceBuffer {
55    #[must_use]
56    pub fn new(capacity: usize) -> Self {
57        Self {
58            capacity: capacity.max(1),
59            next_sequence: 1,
60            traces: VecDeque::new(),
61        }
62    }
63
64    pub fn push(
65        &mut self,
66        kind: RuntimeTraceKind,
67        scope: impl Into<String>,
68        message: impl Into<String>,
69        payload: Option<UiValue>,
70        sensitive: bool,
71    ) {
72        if self.traces.len() == self.capacity {
73            self.traces.pop_front();
74        }
75        self.traces.push_back(RuntimeTrace {
76            sequence: self.next_sequence,
77            kind,
78            scope: scope.into(),
79            message: message.into(),
80            payload: if sensitive && payload.is_some() {
81                Some(UiValue::String("<sensitive>".to_owned()))
82            } else {
83                payload
84            },
85            sensitive,
86        });
87        self.next_sequence = self.next_sequence.saturating_add(1);
88    }
89
90    #[must_use]
91    pub fn snapshot(&self) -> Vec<RuntimeTrace> {
92        self.traces.iter().cloned().collect()
93    }
94}
95
96#[derive(Clone, Debug, PartialEq)]
97pub struct InspectorNode {
98    pub path: String,
99    pub kind: String,
100    pub key: Option<String>,
101    pub source: Option<SourceLocation>,
102    pub props: BTreeMap<String, String>,
103    pub attributes: BTreeMap<String, String>,
104    pub handlers: Vec<String>,
105    pub motions: Vec<String>,
106    pub style: StyleProperties,
107    pub children: Vec<InspectorNode>,
108}
109
110#[derive(Clone, Debug)]
111pub struct InspectorSnapshot {
112    pub root: Option<InspectorNode>,
113    pub state: Vec<StateInstanceSnapshot>,
114    pub stores: Vec<StoreSnapshot>,
115    pub theme_family: String,
116    pub theme_name: String,
117    pub theme_colors: BTreeMap<String, String>,
118    pub traces: Vec<RuntimeTrace>,
119    pub timings: Vec<ExecutionTiming>,
120    pub dirty: Vec<String>,
121    pub components: Vec<InspectorComponent>,
122    pub signals: Vec<InspectorSignal>,
123    pub effects: Vec<InspectorEffect>,
124    pub motions: Vec<InspectorMotion>,
125    pub timelines: Vec<InspectorTimeline>,
126    pub timers: Vec<InspectorTimer>,
127    pub element_refs: Vec<InspectorElementRef>,
128    pub virtual_collections: Vec<InspectorVirtualCollection>,
129    pub geometry_nodes: usize,
130    pub pointer_captures: BTreeMap<u64, u64>,
131    pub locale_readers: usize,
132    pub theme_readers: usize,
133    pub viewport_readers: usize,
134    pub clock_elapsed_ms: u64,
135}
136
137#[derive(Clone, Debug, Eq, PartialEq)]
138pub struct InspectorComponent {
139    pub id: String,
140    pub export: String,
141    pub props: BTreeMap<String, String>,
142    pub slots: Vec<String>,
143    pub parts: Vec<String>,
144}
145
146#[derive(Clone, Debug, Eq, PartialEq)]
147pub struct InspectorSignal {
148    pub id: String,
149    pub kind: String,
150    pub value: String,
151    pub revision: u64,
152    pub last_writer: String,
153}
154
155#[derive(Clone, Debug, Eq, PartialEq)]
156pub struct InspectorEffect {
157    pub id: String,
158    pub activation: u64,
159    pub dependencies: String,
160    pub start: String,
161    pub cleanup: String,
162    pub generation: u64,
163}
164
165#[derive(Clone, Debug, PartialEq)]
166pub struct InspectorMotion {
167    pub id: String,
168    pub kind: String,
169    pub value: f64,
170    pub target: f64,
171    pub velocity: Option<f64>,
172    pub elapsed_ms: u64,
173    pub duration_ms: Option<u64>,
174    pub repeating: bool,
175    pub active: bool,
176}
177
178#[derive(Clone, Debug, Eq, PartialEq)]
179pub struct InspectorTimeline {
180    pub id: String,
181    pub state: String,
182    pub elapsed_ms: u64,
183    pub duration_ms: Option<u64>,
184    pub iterations: Option<u32>,
185    pub autoreverse: bool,
186    pub track_count: usize,
187}
188
189#[derive(Clone, Debug, Eq, PartialEq)]
190pub struct InspectorTimer {
191    pub id: String,
192    pub delay_ms: u64,
193    pub remaining_ms: u64,
194    pub declaration_paused: bool,
195    pub interaction_paused: bool,
196    pub view_paused: bool,
197    pub callback: String,
198    pub generation: u64,
199}
200
201#[derive(Clone, Debug, Eq, PartialEq)]
202pub struct InspectorElementRef {
203    pub id: String,
204    pub node: u64,
205}
206
207#[derive(Clone, Debug, PartialEq)]
208pub struct InspectorVirtualCollection {
209    pub id: String,
210    pub item_count: usize,
211    pub realized_count: usize,
212    pub realized_range: std::ops::Range<usize>,
213    pub requested_count: usize,
214    pub requested_range: std::ops::Range<usize>,
215    pub visible_range: std::ops::Range<usize>,
216    pub viewport_height: f64,
217    pub scroll_item: usize,
218    pub scroll_offset: f64,
219    pub is_scrolled: bool,
220    pub sticky_header: Option<usize>,
221    pub bottom_align: bool,
222    pub follow_tail: bool,
223}
224
225struct InspectorRuntimeMechanisms {
226    signals: Vec<InspectorSignal>,
227    effects: Vec<InspectorEffect>,
228    motions: Vec<InspectorMotion>,
229    timelines: Vec<InspectorTimeline>,
230    timers: Vec<InspectorTimer>,
231    element_refs: Vec<InspectorElementRef>,
232    virtual_collections: Vec<InspectorVirtualCollection>,
233    geometry_nodes: usize,
234    pointer_captures: BTreeMap<u64, u64>,
235    locale_readers: usize,
236    theme_readers: usize,
237    viewport_readers: usize,
238    clock_elapsed_ms: u64,
239}
240
241impl InspectorSnapshot {
242    #[must_use]
243    pub fn capture(
244        root: Option<&UiNode>,
245        runtime: &UiRuntimeState,
246        theme: &ThemeVariant,
247        components: &ComponentRegistry,
248        timings: Vec<ExecutionTiming>,
249    ) -> Self {
250        Self::capture_for_view(root, runtime, theme, components, timings, None)
251    }
252
253    #[must_use]
254    pub fn capture_for_view(
255        root: Option<&UiNode>,
256        runtime: &UiRuntimeState,
257        theme: &ThemeVariant,
258        components: &ComponentRegistry,
259        timings: Vec<ExecutionTiming>,
260        view: Option<&str>,
261    ) -> Self {
262        let mechanisms = inspect_runtime_mechanisms(runtime, view);
263        Self {
264            root: root.map(|root| inspect_node(root, "root")),
265            state: runtime.component_state.inspect(),
266            stores: runtime.stores.inspect(),
267            theme_family: theme.family.clone(),
268            theme_name: theme.name.clone(),
269            theme_colors: theme
270                .tokens
271                .colors
272                .iter()
273                .map(|(name, color)| (name.clone(), format!("#{:08x}", color.as_rgba_hex())))
274                .collect(),
275            traces: runtime.traces.snapshot(),
276            timings,
277            dirty: runtime
278                .dirty_components()
279                .iter()
280                .map(ToString::to_string)
281                .collect(),
282            components: components
283                .iter()
284                .map(|(id, component)| InspectorComponent {
285                    id: id.to_string(),
286                    export: component.metadata.export.clone(),
287                    props: component
288                        .schema
289                        .props
290                        .iter()
291                        .map(|(name, field)| {
292                            (
293                                name.clone(),
294                                if field.sensitive {
295                                    "<sensitive>".to_owned()
296                                } else {
297                                    format!("{:?}", field.schema)
298                                },
299                            )
300                        })
301                        .collect(),
302                    slots: component.schema.slots.keys().cloned().collect(),
303                    parts: component.schema.parts.iter().cloned().collect(),
304                })
305                .collect(),
306            signals: mechanisms.signals,
307            effects: mechanisms.effects,
308            motions: mechanisms.motions,
309            timelines: mechanisms.timelines,
310            timers: mechanisms.timers,
311            element_refs: mechanisms.element_refs,
312            virtual_collections: mechanisms.virtual_collections,
313            geometry_nodes: mechanisms.geometry_nodes,
314            pointer_captures: mechanisms.pointer_captures,
315            locale_readers: mechanisms.locale_readers,
316            theme_readers: mechanisms.theme_readers,
317            viewport_readers: mechanisms.viewport_readers,
318            clock_elapsed_ms: mechanisms.clock_elapsed_ms,
319        }
320    }
321}
322
323#[allow(clippy::too_many_lines)]
324fn inspect_runtime_mechanisms(
325    runtime: &UiRuntimeState,
326    view: Option<&str>,
327) -> InspectorRuntimeMechanisms {
328    let geometry = runtime.geometry_for(view);
329    let pointer_capture = runtime.pointer_capture_for(view);
330    InspectorRuntimeMechanisms {
331        signals: runtime
332            .signals
333            .inspect()
334            .into_iter()
335            .map(|signal| InspectorSignal {
336                id: format!(
337                    "{}/{}:{}",
338                    signal.id.component(),
339                    signal.id.key(),
340                    signal.id.kind().as_str()
341                ),
342                kind: signal.id.kind().as_str().to_owned(),
343                value: format!("{:?}", signal.value),
344                revision: signal.revision,
345                last_writer: format!("{:?}", signal.last_writer),
346            })
347            .collect(),
348        effects: runtime
349            .effects
350            .iter_active()
351            .map(|(id, effect, activation)| InspectorEffect {
352                id: format!("{}/{}", id.component(), id.key()),
353                activation,
354                dependencies: display_value(effect.dependencies(), false),
355                start: effect.start().name().to_owned(),
356                cleanup: effect.cleanup().name().to_owned(),
357                generation: effect.generation().get(),
358            })
359            .collect(),
360        motions: runtime
361            .motions
362            .inspect(runtime.clock.now())
363            .into_iter()
364            .map(|animation| InspectorMotion {
365                id: format!(
366                    "{}/{}",
367                    animation.key.component,
368                    animation.key.property.as_str()
369                ),
370                kind: animation.kind,
371                value: animation.value,
372                target: animation.target,
373                velocity: animation.velocity,
374                elapsed_ms: animation.elapsed_ms,
375                duration_ms: animation.duration_ms,
376                repeating: animation.repeating,
377                active: animation.active,
378            })
379            .collect(),
380        timelines: runtime
381            .motions
382            .inspect_timelines(runtime.clock.now())
383            .into_iter()
384            .map(|timeline| InspectorTimeline {
385                id: format!(
386                    "{}/{}/{}",
387                    timeline.handle.domain(),
388                    timeline.handle.owner(),
389                    timeline.handle.name()
390                ),
391                state: format!("{:?}", timeline.state),
392                elapsed_ms: timeline.elapsed_ms,
393                duration_ms: timeline.duration_ms,
394                iterations: timeline.iterations,
395                autoreverse: timeline.autoreverse,
396                track_count: timeline.track_count,
397            })
398            .collect(),
399        timers: runtime
400            .timers
401            .inspect(runtime.clock.now())
402            .into_iter()
403            .map(|timer| InspectorTimer {
404                id: format!("{}/{}", timer.id.component(), timer.id.key()),
405                delay_ms: u64::try_from(timer.delay.as_millis()).unwrap_or(u64::MAX),
406                remaining_ms: u64::try_from(timer.remaining.as_millis()).unwrap_or(u64::MAX),
407                declaration_paused: timer.declaration_paused,
408                interaction_paused: timer.interaction_paused,
409                view_paused: timer.view_paused,
410                callback: timer.callback,
411                generation: timer.generation.get(),
412            })
413            .collect(),
414        element_refs: runtime
415            .element_refs
416            .iter()
417            .map(|(id, node)| InspectorElementRef {
418                id: format!("{}/{}", id.component(), id.key()),
419                node: node.get(),
420            })
421            .collect(),
422        virtual_collections: inspect_virtual_collections(runtime),
423        geometry_nodes: geometry.len(),
424        pointer_captures: pointer_capture
425            .snapshot()
426            .into_iter()
427            .map(|(pointer, node)| (pointer, node.get()))
428            .collect(),
429        locale_readers: runtime.environment_dependencies.locale_reader_count(),
430        theme_readers: runtime.environment_dependencies.theme_reader_count(),
431        viewport_readers: runtime.environment_dependencies.viewport_reader_count(),
432        clock_elapsed_ms: u64::try_from(runtime.clock.elapsed().as_millis()).unwrap_or(u64::MAX),
433    }
434}
435
436fn inspect_virtual_collections(runtime: &UiRuntimeState) -> Vec<InspectorVirtualCollection> {
437    runtime
438        .virtual_requests
439        .inspect()
440        .into_iter()
441        .map(|metrics| InspectorVirtualCollection {
442            id: format!("{}/{}", metrics.id.component, metrics.id.key),
443            item_count: metrics.item_count,
444            realized_count: metrics.realized_count,
445            realized_range: metrics.realized_range,
446            requested_count: metrics.requested_count,
447            requested_range: metrics.requested_range,
448            visible_range: metrics.visible_range,
449            viewport_height: metrics.viewport_height,
450            scroll_item: metrics.scroll_item,
451            scroll_offset: metrics.scroll_offset,
452            is_scrolled: metrics.is_scrolled,
453            sticky_header: metrics.sticky_header,
454            bottom_align: metrics.bottom_align,
455            follow_tail: metrics.follow_tail,
456        })
457        .collect()
458}
459
460fn inspect_node(node: &UiNode, path: &str) -> InspectorNode {
461    let mut props = BTreeMap::new();
462    let (kind, descendants): (String, Vec<&UiNode>) = match node.kind() {
463        UiNodeKind::Text { text } => {
464            props.insert("text".to_owned(), truncate(text, 80));
465            ("text".to_owned(), Vec::new())
466        }
467        UiNodeKind::RichText { text, spans } => {
468            (inspect_rich_text(text, spans, &mut props), Vec::new())
469        }
470        UiNodeKind::Canvas { scene } => (inspect_canvas(scene, &mut props), Vec::new()),
471        UiNodeKind::Svg { source } => {
472            props.insert("bytes".to_owned(), source.len().to_string());
473            ("svg".to_owned(), Vec::new())
474        }
475        UiNodeKind::Box { children } => ("box".to_owned(), children.iter().collect()),
476        UiNodeKind::Fragment { children } => ("fragment".to_owned(), children.iter().collect()),
477        UiNodeKind::Custom { primitive } => {
478            props = inspect_primitive_props(primitive);
479            (
480                format!("primitive:{}", primitive.primitive.as_str()),
481                Vec::new(),
482            )
483        }
484        UiNodeKind::Image { source } => inspect_image(source, &mut props),
485        UiNodeKind::DirectionalImage {
486            left_to_right,
487            right_to_left,
488        } => inspect_directional_image(left_to_right, right_to_left, &mut props),
489        UiNodeKind::Overlay {
490            trigger,
491            content,
492            spec,
493        } => {
494            props.insert("id".to_owned(), spec.id.as_str().to_owned());
495            props.insert("kind".to_owned(), format!("{:?}", spec.kind));
496            props.insert("placement".to_owned(), format!("{:?}", spec.placement));
497            props.insert("open".to_owned(), spec.open.to_string());
498            (
499                "overlay".to_owned(),
500                vec![trigger.as_ref(), content.as_ref()],
501            )
502        }
503        UiNodeKind::Layer { content, spec } => {
504            props.insert("id".to_owned(), spec.id.as_str().to_owned());
505            props.insert("placement".to_owned(), format!("{:?}", spec.placement));
506            props.insert("priority".to_owned(), spec.priority.to_string());
507            ("layer".to_owned(), vec![content.as_ref()])
508        }
509        UiNodeKind::VirtualCollection { spec } => inspect_virtual_collection(spec, &mut props),
510        UiNodeKind::ErrorBoundary { child, fallback } => (
511            "error_boundary".to_owned(),
512            vec![child.as_ref(), fallback.as_ref()],
513        ),
514    };
515    let children = descendants
516        .into_iter()
517        .enumerate()
518        .map(|(index, child)| {
519            let segment = child
520                .key()
521                .map_or_else(|| index.to_string(), |key| format!("key:{}", key.as_str()));
522            inspect_node(child, &format!("{path}/{segment}"))
523        })
524        .collect();
525    InspectorNode {
526        path: path.to_owned(),
527        kind,
528        key: node.key().map(|key| key.as_str().to_owned()),
529        source: node.source().cloned(),
530        props,
531        attributes: node
532            .attributes()
533            .iter()
534            .map(|(name, value)| (name.clone(), display_value(value, false)))
535            .collect(),
536        handlers: node
537            .handlers()
538            .iter()
539            .flat_map(|(event, bindings)| {
540                bindings.iter().map(move |binding| {
541                    format!(
542                        "{event}:{:?}={}",
543                        binding.phase(),
544                        binding.handler().diagnostic_label()
545                    )
546                })
547            })
548            .collect(),
549        motions: node
550            .motions()
551            .iter()
552            .map(|animation| format!("{animation:?}"))
553            .collect(),
554        style: node.style().resolve(&crate::InteractionState::default()),
555        children,
556    }
557}
558
559fn inspect_primitive_props(primitive: &crate::PrimitiveNode) -> BTreeMap<String, String> {
560    let props = primitive
561        .props
562        .iter()
563        .map(|(name, value)| (name.to_owned(), primitive_value(value)))
564        .collect();
565    #[cfg(feature = "charts")]
566    {
567        let mut props = props;
568        if primitive.primitive.as_str() == "gpui_rhai.chart" {
569            inspect_chart_primitive(primitive, &mut props);
570        }
571        props
572    }
573    #[cfg(not(feature = "charts"))]
574    {
575        props
576    }
577}
578
579#[cfg(feature = "charts")]
580fn inspect_chart_primitive(primitive: &crate::PrimitiveNode, props: &mut BTreeMap<String, String>) {
581    let Some(crate::PrimitiveValue::Data(spec)) = primitive.props.get("spec") else {
582        return;
583    };
584    let spec = match crate::ChartSpec::from_ui_value(spec) {
585        Ok(spec) => spec,
586        Err(error) => {
587            props.insert("chart_config_error".to_owned(), error.to_string());
588            return;
589        }
590    };
591    let lengths = match primitive.props.get("data") {
592        Some(crate::PrimitiveValue::ChartData(data)) => data
593            .snapshot()
594            .datasets()
595            .map(|(name, dataset)| (name.to_owned(), dataset.len()))
596            .collect::<BTreeMap<_, _>>(),
597        Some(crate::PrimitiveValue::Data(crate::UiValue::Array(rows))) => {
598            BTreeMap::from([("main".to_owned(), rows.len())])
599        }
600        _ => BTreeMap::new(),
601    };
602    let sampling = spec
603        .series
604        .iter()
605        .filter_map(|series| {
606            let source = lengths.get(&series.dataset).copied()?;
607            let explicit = series
608                .transforms
609                .iter()
610                .find_map(|transform| match transform {
611                    crate::ChartTransformSpec::Downsample { threshold, .. } => Some(*threshold),
612                    _ => None,
613                });
614            let target = explicit.or_else(|| {
615                matches!(
616                    series.kind,
617                    crate::ChartSeriesKind::Line | crate::ChartSeriesKind::Area
618                )
619                .then_some(4_000)
620            })?;
621            (source > target).then(|| format!("{}:{target}/{source}", series.key))
622        })
623        .collect::<Vec<_>>();
624    if !sampling.is_empty() {
625        props.insert("chart_sampling_plan".to_owned(), sampling.join(", "));
626    }
627}
628
629fn inspect_virtual_collection<'a>(
630    spec: &'a crate::VirtualCollectionNodeSpec,
631    props: &mut BTreeMap<String, String>,
632) -> (String, Vec<&'a UiNode>) {
633    props.insert("items".to_owned(), spec.data.len().to_string());
634    props.insert("realized".to_owned(), spec.realized.len().to_string());
635    (
636        "virtual_collection".to_owned(),
637        spec.realized.values().collect(),
638    )
639}
640
641fn inspect_rich_text(
642    text: &str,
643    spans: &[crate::Span],
644    props: &mut BTreeMap<String, String>,
645) -> String {
646    props.insert("text".to_owned(), truncate(text, 80));
647    props.insert("span_count".to_owned(), spans.len().to_string());
648    "rich_text".to_owned()
649}
650
651fn inspect_canvas(scene: &crate::CanvasScene, props: &mut BTreeMap<String, String>) -> String {
652    props.insert(
653        "command_count".to_owned(),
654        scene.commands().len().to_string(),
655    );
656    "canvas".to_owned()
657}
658
659fn inspect_image<'a>(
660    source: &crate::ImageSourceSpec,
661    props: &mut BTreeMap<String, String>,
662) -> (String, Vec<&'a UiNode>) {
663    props.insert("source".to_owned(), inspect_image_source(source));
664    ("image".to_owned(), Vec::new())
665}
666
667fn inspect_directional_image<'a>(
668    left_to_right: &crate::ImageSourceSpec,
669    right_to_left: &crate::ImageSourceSpec,
670    props: &mut BTreeMap<String, String>,
671) -> (String, Vec<&'a UiNode>) {
672    props.insert(
673        "left_to_right".to_owned(),
674        inspect_image_source(left_to_right),
675    );
676    props.insert(
677        "right_to_left".to_owned(),
678        inspect_image_source(right_to_left),
679    );
680    ("directional_image".to_owned(), Vec::new())
681}
682
683fn inspect_image_source(source: &crate::ImageSourceSpec) -> String {
684    match source {
685        crate::ImageSourceSpec::Handle(handle) => format!("{}#<opaque>", handle.kind()),
686        crate::ImageSourceSpec::Asset(asset) => asset.as_str().to_owned(),
687    }
688}
689
690fn primitive_value(value: &PrimitiveValue) -> String {
691    match value {
692        PrimitiveValue::Data(value) => display_value(value, false),
693        PrimitiveValue::Node(_) => "<node>".to_owned(),
694        PrimitiveValue::Nodes(nodes) => format!("<{} nodes>", nodes.len()),
695        PrimitiveValue::Callback(callback) => callback.diagnostic_label(),
696        PrimitiveValue::Style(_) => "<style>".to_owned(),
697        PrimitiveValue::Length(length) => format!("{length:?}"),
698        PrimitiveValue::Asset(asset) => asset.as_str().to_owned(),
699        PrimitiveValue::Signal(signal) => format!("signal:{}", signal.id().key()),
700        PrimitiveValue::Ref(reference) => format!("ref:{}", reference.id().key()),
701        PrimitiveValue::Document(document) => format!(
702            "{}@{} ({} bytes)",
703            document.identity(),
704            document.revision(),
705            document.text().len()
706        ),
707        #[cfg(feature = "charts")]
708        PrimitiveValue::ChartData(data) => format!("chart-data@{}", data.revision()),
709    }
710}
711
712fn display_value(value: &UiValue, sensitive: bool) -> String {
713    if sensitive {
714        return "<redacted>".to_owned();
715    }
716    match value {
717        UiValue::Null => "null".to_owned(),
718        UiValue::Bool(value) => value.to_string(),
719        UiValue::Integer(value) => value.to_string(),
720        UiValue::Float(value) => value.to_string(),
721        UiValue::String(value) => format!("{:?}", truncate(value, 80)),
722        UiValue::Array(values) => format!("[{} items]", values.len()),
723        UiValue::Map(values) => format!("{{{} fields}}", values.len()),
724        UiValue::Handle(handle) => format!("{}#<opaque>", handle.kind()),
725    }
726}
727
728fn truncate(value: &str, max: usize) -> String {
729    let mut output = value.chars().take(max).collect::<String>();
730    if value.chars().count() > max {
731        output.push('…');
732    }
733    output
734}
735
736#[must_use]
737#[cfg(feature = "dev-reload")]
738pub(crate) fn inspector_element(snapshot: &InspectorSnapshot) -> AnyElement {
739    let mut lines = inspector_header(snapshot);
740    if let Some(root) = &snapshot.root {
741        append_node_lines(root, 0, &mut lines);
742    }
743    lines.push("Components".to_owned());
744    for component in &snapshot.components {
745        lines.push(format!("  {}::{}", component.id, component.export));
746        lines.push(format!("    props={:?}", component.props));
747        lines.push(format!("    slots={:?}", component.slots));
748        lines.push(format!("    parts={:?}", component.parts));
749    }
750    append_mechanism_lines(snapshot, &mut lines);
751    lines.push("State".to_owned());
752    for instance in &snapshot.state {
753        lines.push(format!("  {}", instance.path));
754        for (name, value) in &instance.fields {
755            lines.push(format!(
756                "    {name}: {}",
757                display_value(&value.value, value.sensitive)
758            ));
759        }
760    }
761    lines.push("Stores".to_owned());
762    for store in &snapshot.stores {
763        lines.push(format!("  {:?}:{}", store.id.scope, store.id.name));
764        for (name, value) in &store.fields {
765            lines.push(format!(
766                "    {name}: {}",
767                display_value(&value.value, value.sensitive)
768            ));
769        }
770    }
771    lines.push("Theme tokens".to_owned());
772    for (name, value) in &snapshot.theme_colors {
773        lines.push(format!("  {name}: {value}"));
774    }
775    lines.push("Invalidations".to_owned());
776    if snapshot.dirty.is_empty() {
777        lines.push("  none pending".to_owned());
778    } else {
779        lines.extend(snapshot.dirty.iter().map(|path| format!("  dirty: {path}")));
780    }
781    lines.push("Script timings".to_owned());
782    for timing in snapshot.timings.iter().rev().take(20) {
783        lines.push(format!(
784            "  {:?} {} {:.2?} {} ops(v{}){}",
785            timing.operation,
786            timing.source,
787            timing.duration,
788            timing.operations,
789            timing.operation_semantics,
790            if timing.slow { " SLOW" } else { "" }
791        ));
792    }
793    lines.push("Recent traces".to_owned());
794    for trace in snapshot.traces.iter().rev().take(20) {
795        lines.push(format!(
796            "  #{} {:?} {} — {} {}",
797            trace.sequence,
798            trace.kind,
799            trace.scope,
800            trace.message,
801            trace
802                .payload
803                .as_ref()
804                .map_or_else(String::new, |value| display_value(value, trace.sensitive))
805        ));
806    }
807    deferred(
808        div()
809            .id("gpui-rhai-inspector")
810            .absolute()
811            .top_0()
812            .right_0()
813            .w(px(420.0))
814            .h_full()
815            .overflow_y_scroll()
816            .restrict_scroll_to_axis()
817            .p_3()
818            .bg(rgba(0x1717_1bee))
819            .text_color(rgba(0xf4f4_f5ff))
820            .text_size(px(11.0))
821            .children(lines.into_iter().map(|line| div().child(line))),
822    )
823    .with_priority(10_000)
824    .into_any_element()
825}
826
827#[cfg(feature = "dev-reload")]
828fn inspector_header(snapshot: &InspectorSnapshot) -> Vec<String> {
829    vec![
830        format!(
831            "GPUI Rhai Inspector — {} / {}",
832            snapshot.theme_family, snapshot.theme_name
833        ),
834        format!(
835            "nodes={} state={} stores={} signals={} effects={} motions={} timelines={} timers={} refs={} virtual={} geometry={} captures={} dirty={} traces={} timings={} clock={}ms",
836            snapshot.root.as_ref().map_or(0, count_nodes),
837            snapshot.state.len(),
838            snapshot.stores.len(),
839            snapshot.signals.len(),
840            snapshot.effects.len(),
841            snapshot.motions.len(),
842            snapshot.timelines.len(),
843            snapshot.timers.len(),
844            snapshot.element_refs.len(),
845            snapshot.virtual_collections.len(),
846            snapshot.geometry_nodes,
847            snapshot.pointer_captures.len(),
848            snapshot.dirty.len(),
849            snapshot.traces.len(),
850            snapshot.timings.len(),
851            snapshot.clock_elapsed_ms
852        ),
853    ]
854}
855
856#[cfg(feature = "dev-reload")]
857fn append_mechanism_lines(snapshot: &InspectorSnapshot, lines: &mut Vec<String>) {
858    lines.push(format!(
859        "Dependencies — locale={} theme={} viewport={}",
860        snapshot.locale_readers, snapshot.theme_readers, snapshot.viewport_readers
861    ));
862    lines.push("Signals".to_owned());
863    for signal in &snapshot.signals {
864        lines.push(format!(
865            "  {} {}={} rev={} writer={}",
866            signal.id, signal.kind, signal.value, signal.revision, signal.last_writer
867        ));
868    }
869    lines.push("Effects".to_owned());
870    for effect in &snapshot.effects {
871        lines.push(format!(
872            "  {} activation={} deps={} start={} cleanup={} gen={}",
873            effect.id,
874            effect.activation,
875            effect.dependencies,
876            effect.start,
877            effect.cleanup,
878            effect.generation
879        ));
880    }
881    lines.push("Motion sources".to_owned());
882    for motion in &snapshot.motions {
883        lines.push(format!(
884            "  {} {} value={:.4} target={:.4} velocity={:?} elapsed={}ms duration={:?} repeat={} active={}",
885            motion.id,
886            motion.kind,
887            motion.value,
888            motion.target,
889            motion.velocity,
890            motion.elapsed_ms,
891            motion.duration_ms,
892            motion.repeating,
893            motion.active
894        ));
895    }
896    lines.push("Motion timelines".to_owned());
897    for timeline in &snapshot.timelines {
898        lines.push(format!(
899            "  {} state={} elapsed={}ms duration={:?} iterations={:?} reverse={} tracks={}",
900            timeline.id,
901            timeline.state,
902            timeline.elapsed_ms,
903            timeline.duration_ms,
904            timeline.iterations,
905            timeline.autoreverse,
906            timeline.track_count
907        ));
908    }
909    lines.push("Timers".to_owned());
910    for timer in &snapshot.timers {
911        lines.push(format!(
912            "  {} remaining={}ms/{}ms paused={}/{}/{} callback={} gen={}",
913            timer.id,
914            timer.remaining_ms,
915            timer.delay_ms,
916            timer.declaration_paused,
917            timer.interaction_paused,
918            timer.view_paused,
919            timer.callback,
920            timer.generation
921        ));
922    }
923    lines.push("Element refs".to_owned());
924    for reference in &snapshot.element_refs {
925        lines.push(format!("  {} -> node {}", reference.id, reference.node));
926    }
927    lines.push("Virtual collections".to_owned());
928    for collection in &snapshot.virtual_collections {
929        lines.push(format!(
930            "  {} items={} realized={}:{:?} requested={}:{:?} visible={:?} viewport={:.1} top={}:{} sticky={:?} scrolled={} bottom={} follow_tail={}",
931            collection.id,
932            collection.item_count,
933            collection.realized_count,
934            collection.realized_range,
935            collection.requested_count,
936            collection.requested_range,
937            collection.visible_range,
938            collection.viewport_height,
939            collection.scroll_item,
940            collection.scroll_offset,
941            collection.sticky_header,
942            collection.is_scrolled,
943            collection.bottom_align,
944            collection.follow_tail
945        ));
946    }
947    if !snapshot.pointer_captures.is_empty() {
948        lines.push(format!("Pointer captures: {:?}", snapshot.pointer_captures));
949    }
950}
951
952#[cfg(feature = "dev-reload")]
953fn count_nodes(node: &InspectorNode) -> usize {
954    1 + node.children.iter().map(count_nodes).sum::<usize>()
955}
956
957#[cfg(feature = "dev-reload")]
958fn append_node_lines(node: &InspectorNode, depth: usize, lines: &mut Vec<String>) {
959    let source = node.source.as_ref().map_or_else(String::new, |source| {
960        format!(" @ {}:{}:{}", source.module, source.line, source.column)
961    });
962    lines.push(format!(
963        "{}{} {}{}",
964        "  ".repeat(depth),
965        node.path,
966        node.kind,
967        source
968    ));
969    let indent = "  ".repeat(depth.saturating_add(1));
970    if let Some(key) = &node.key {
971        lines.push(format!("{indent}key={key}"));
972    }
973    if !node.props.is_empty() {
974        lines.push(format!("{indent}props={:?}", node.props));
975    }
976    if !node.attributes.is_empty() {
977        lines.push(format!("{indent}attributes={:?}", node.attributes));
978    }
979    if !node.handlers.is_empty() {
980        lines.push(format!("{indent}handlers={:?}", node.handlers));
981    }
982    if !node.motions.is_empty() {
983        lines.push(format!("{indent}motions={:?}", node.motions));
984    }
985    lines.push(format!("{indent}computed_style={:?}", node.style));
986    for child in &node.children {
987        append_node_lines(child, depth.saturating_add(1), lines);
988    }
989}
990
991#[cfg(test)]
992mod tests {
993    use super::*;
994    use crate::{
995        ComponentInstancePath, ComponentStateSchema, EventPropagation, HostCallback, StateField,
996        ValueSchema,
997    };
998    use std::time::{Duration, Instant};
999
1000    #[test]
1001    fn inspector_reports_host_handler_label_without_opaque_details() {
1002        let root = UiNode::text("Host").with_host_handler(
1003            "click",
1004            HostCallback::new("widget.select", |_, _, _| EventPropagation::Handled),
1005        );
1006        let inspected = inspect_node(&root, "root");
1007        assert_eq!(inspected.handlers, vec!["click:Target=host:widget.select"]);
1008    }
1009
1010    #[test]
1011    fn snapshot_captures_source_and_redacts_sensitive_state() {
1012        let mut runtime = UiRuntimeState::new();
1013        let path = ComponentInstancePath::root("Login", "root");
1014        let schema = ComponentStateSchema::new(BTreeMap::from([(
1015            "token".to_owned(),
1016            StateField::new(ValueSchema::string(), UiValue::String("secret".to_owned()))
1017                .sensitive(true),
1018        )]))
1019        .unwrap();
1020        let mut transaction = runtime.component_state.begin_render();
1021        transaction.mount(path, &schema).unwrap();
1022        runtime.component_state.commit_render(transaction);
1023        let root = UiNode::text("Login").with_source(SourceLocation {
1024            module: "ui/main.rhai".to_owned(),
1025            line: 4,
1026            column: 9,
1027        });
1028        let engine = crate::RuntimeEngine::new();
1029        let theme = crate::load_theme_source(
1030            engine.engine(),
1031            "theme.rhai",
1032            include_str!("../../../registry/themes/default_dark.rhai"),
1033        )
1034        .unwrap();
1035        let snapshot = InspectorSnapshot::capture(
1036            Some(&root),
1037            &runtime,
1038            &theme,
1039            &ComponentRegistry::default(),
1040            Vec::new(),
1041        );
1042        assert_eq!(snapshot.root.unwrap().source.unwrap().line, 4);
1043        assert!(snapshot.state[0].fields["token"].sensitive);
1044        assert_eq!(
1045            snapshot.state[0].fields["token"].value,
1046            UiValue::String("<sensitive>".to_owned())
1047        );
1048        assert_eq!(
1049            display_value(&snapshot.state[0].fields["token"].value, true),
1050            "<redacted>"
1051        );
1052    }
1053
1054    #[test]
1055    fn trace_buffer_is_bounded_and_ordered() {
1056        let mut traces = TraceBuffer::new(2);
1057        for index in 0..3 {
1058            traces.push(
1059                RuntimeTraceKind::Event,
1060                "root",
1061                format!("event-{index}"),
1062                None,
1063                false,
1064            );
1065        }
1066        let snapshot = traces.snapshot();
1067        assert_eq!(snapshot.len(), 2);
1068        assert_eq!(snapshot[0].sequence, 2);
1069        assert_eq!(snapshot[1].sequence, 3);
1070    }
1071
1072    #[test]
1073    fn sensitive_trace_payload_is_redacted_before_storage() {
1074        let mut traces = TraceBuffer::new(1);
1075        traces.push(
1076            RuntimeTraceKind::State,
1077            "login",
1078            "token changed",
1079            Some(UiValue::String("secret".to_owned())),
1080            true,
1081        );
1082        assert_eq!(
1083            traces.snapshot()[0].payload,
1084            Some(UiValue::String("<sensitive>".to_owned()))
1085        );
1086    }
1087
1088    fn install_virtual_metrics_probe(runtime: &UiRuntimeState, component: &ComponentInstancePath) {
1089        runtime.virtual_requests.request(
1090            crate::VirtualCollectionId {
1091                component: component.clone(),
1092                key: "rows".to_owned(),
1093            },
1094            [4, 5],
1095        );
1096    }
1097
1098    #[test]
1099    fn snapshot_includes_runtime_mechanism_identity_and_state() {
1100        let component = ComponentInstancePath::root("View", "main");
1101        let mut runtime = UiRuntimeState::new();
1102        let now = Instant::now();
1103        let clock = crate::ManualRuntimeClock::new(now);
1104        runtime.clock = clock.clock();
1105        let signal_id =
1106            crate::SignalId::new(component.clone(), "progress", crate::SignalKind::Float).unwrap();
1107        runtime.signals.reconcile(
1108            &component,
1109            BTreeMap::from([(
1110                signal_id.clone(),
1111                crate::signal::SignalDescriptor::new(crate::SignalValue::Float(0.25)),
1112            )]),
1113        );
1114        let signal = crate::NativeSignal::new(signal_id);
1115        runtime
1116            .signals
1117            .write_from(
1118                &signal,
1119                crate::SignalValue::Float(0.5),
1120                crate::SignalWriter::Script,
1121            )
1122            .unwrap();
1123
1124        let mut callback = crate::ScriptCallback::try_from_fn_ptr(
1125            rhai::FnPtr::new("fired").unwrap(),
1126            crate::ScriptGeneration::initial(),
1127        )
1128        .unwrap();
1129        callback.bind_component_if_unset(component.clone(), BTreeMap::new());
1130        let timer = crate::TimerDescriptor::new(
1131            crate::TimerId::new(component.clone(), "refresh").unwrap(),
1132            Duration::from_secs(1),
1133            false,
1134            callback,
1135            UiValue::Null,
1136        )
1137        .unwrap();
1138        runtime.timers.reconcile(
1139            &component,
1140            BTreeMap::from([(timer.id().clone(), timer)]),
1141            now,
1142        );
1143        let mut transition =
1144            crate::MotionTransition::new(crate::MotionProperty::Opacity, 0.0, 1.0, 1_000);
1145        transition.easing = crate::MotionEasing::Linear;
1146        runtime
1147            .motions
1148            .start(
1149                component.clone(),
1150                crate::MotionSource::Transition(transition),
1151                now,
1152            )
1153            .unwrap();
1154        clock.advance(Duration::from_millis(250));
1155
1156        let mut retained = crate::RetainedUiTree::new();
1157        retained.reconcile(UiNode::text("target")).unwrap();
1158        let node = retained.root_id().unwrap();
1159        let reference = crate::ElementRefId::new(component.clone(), "target").unwrap();
1160        runtime
1161            .element_refs
1162            .reconcile(&component, BTreeMap::from([(reference, node)]));
1163        runtime.pointer_capture.capture(7, node);
1164        let bounds = crate::GeometryBounds::new(0.0, 0.0, 10.0, 10.0).unwrap();
1165        runtime.geometry.update(
1166            node,
1167            crate::ElementGeometry {
1168                layout: bounds,
1169                visual: bounds,
1170                clip: None,
1171            },
1172        );
1173        install_virtual_metrics_probe(&runtime, &component);
1174
1175        let engine = crate::RuntimeEngine::new();
1176        let theme = crate::load_theme_source(
1177            engine.engine(),
1178            "theme.rhai",
1179            include_str!("../../../registry/themes/default_dark.rhai"),
1180        )
1181        .unwrap();
1182        let snapshot = InspectorSnapshot::capture(
1183            retained.root(),
1184            &runtime,
1185            &theme,
1186            &ComponentRegistry::default(),
1187            Vec::new(),
1188        );
1189        assert_eq!(snapshot.signals[0].revision, 1);
1190        assert_eq!(snapshot.signals[0].last_writer, "Script");
1191        assert_eq!(snapshot.timers[0].callback, "fired");
1192        assert_eq!(snapshot.clock_elapsed_ms, 250);
1193        assert_eq!(snapshot.motions[0].kind, "transition");
1194        assert!((snapshot.motions[0].value - 0.25).abs() < f64::EPSILON);
1195        assert_eq!(snapshot.motions[0].duration_ms, Some(1_000));
1196        assert_eq!(snapshot.element_refs[0].node, node.get());
1197        assert_eq!(snapshot.virtual_collections[0].requested_range, 4..6);
1198        assert_eq!(snapshot.geometry_nodes, 1);
1199        assert_eq!(snapshot.pointer_captures.get(&7), Some(&node.get()));
1200    }
1201}