Skip to main content

gpui_rhai/
state.rs

1use std::collections::{BTreeMap, BTreeSet};
2use std::fmt;
3use std::rc::Rc;
4
5use serde::{Deserialize, Serialize};
6use thiserror::Error;
7
8use crate::{SchemaDefinitionError, SchemaValidationError, UiValue, ValueSchema};
9
10#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize, Deserialize)]
11pub struct ComponentInstancePath(Vec<ComponentInstanceSegment>);
12
13#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize, Deserialize)]
14struct ComponentInstanceSegment {
15    component: String,
16    key: String,
17}
18
19impl ComponentInstancePath {
20    #[must_use]
21    pub fn root(component: impl Into<String>, key: impl Into<String>) -> Self {
22        Self(vec![ComponentInstanceSegment {
23            component: component.into(),
24            key: key.into(),
25        }])
26    }
27
28    #[must_use]
29    pub fn child(&self, component: impl Into<String>, key: impl Into<String>) -> Self {
30        let mut segments = self.0.clone();
31        segments.push(ComponentInstanceSegment {
32            component: component.into(),
33            key: key.into(),
34        });
35        Self(segments)
36    }
37
38    #[must_use]
39    pub fn parent(&self) -> Option<Self> {
40        if self.0.len() <= 1 {
41            return None;
42        }
43        let mut segments = self.0.clone();
44        segments.pop();
45        Some(Self(segments))
46    }
47
48    #[must_use]
49    pub fn leaf_key(&self) -> Option<&str> {
50        self.0.last().map(|segment| segment.key.as_str())
51    }
52
53    #[must_use]
54    pub fn is_within(&self, ancestor: &Self) -> bool {
55        self.0.starts_with(&ancestor.0)
56    }
57
58    pub(crate) fn single_root_key(&self, component: &str) -> Option<&str> {
59        (self.0.len() == 1 && self.0[0].component == component).then_some(self.0[0].key.as_str())
60    }
61}
62
63impl fmt::Display for ComponentInstancePath {
64    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
65        for segment in &self.0 {
66            write!(formatter, "/{}[{}]", segment.component, segment.key)?;
67        }
68        Ok(())
69    }
70}
71
72pub(crate) fn topmost_paths(paths: &BTreeSet<ComponentInstancePath>) -> Vec<ComponentInstancePath> {
73    let mut roots = Vec::<ComponentInstancePath>::new();
74    // Lexicographic component paths place each ancestor before its contiguous
75    // subtree. Keep disjoint roots without a quadratic all-pairs scan.
76    for path in paths {
77        if roots.last().is_none_or(|root| !path.is_within(root)) {
78            roots.push(path.clone());
79        }
80    }
81    roots
82}
83
84#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
85pub struct StateField {
86    pub schema: ValueSchema,
87    pub default: UiValue,
88    #[serde(default)]
89    pub sensitive: bool,
90}
91
92impl StateField {
93    #[must_use]
94    pub fn new(schema: ValueSchema, default: UiValue) -> Self {
95        Self {
96            schema,
97            default,
98            sensitive: false,
99        }
100    }
101
102    #[must_use]
103    pub const fn sensitive(mut self, sensitive: bool) -> Self {
104        self.sensitive = sensitive;
105        self
106    }
107}
108
109#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
110pub struct ComponentStateSchema {
111    fields: BTreeMap<String, StateField>,
112}
113
114impl ComponentStateSchema {
115    /// Create a state schema after validating every declared default.
116    ///
117    /// # Errors
118    ///
119    /// Returns [`StateError::InvalidDefault`] when a default does not satisfy
120    /// its field schema.
121    pub fn new(fields: BTreeMap<String, StateField>) -> Result<Self, StateError> {
122        for (name, field) in &fields {
123            field
124                .schema
125                .validate_definition()
126                .map_err(|source| StateError::InvalidSchema {
127                    field: name.clone(),
128                    source,
129                })?;
130            field
131                .schema
132                .validate_ui_value(&field.default)
133                .map_err(|source| StateError::InvalidDefault {
134                    field: name.clone(),
135                    source,
136                })?;
137        }
138        Ok(Self { fields })
139    }
140
141    #[must_use]
142    pub fn is_empty(&self) -> bool {
143        self.fields.is_empty()
144    }
145
146    #[must_use]
147    pub fn field(&self, name: &str) -> Option<&StateField> {
148        self.fields.get(name)
149    }
150
151    #[must_use]
152    pub fn fields(&self) -> &BTreeMap<String, StateField> {
153        &self.fields
154    }
155
156    fn defaults(&self) -> BTreeMap<String, UiValue> {
157        self.fields
158            .iter()
159            .map(|(name, field)| (name.clone(), field.default.clone()))
160            .collect()
161    }
162}
163
164#[derive(Clone, Debug, Default)]
165pub struct StateStore {
166    instances: Rc<BTreeMap<ComponentInstancePath, ComponentState>>,
167}
168
169#[derive(Clone, Debug)]
170struct ComponentState {
171    schema: Rc<ComponentStateSchema>,
172    values: Rc<BTreeMap<String, UiValue>>,
173}
174
175impl StateStore {
176    #[must_use]
177    pub fn new() -> Self {
178        Self::default()
179    }
180
181    /// Start an isolated render-state transaction.
182    ///
183    /// Dropping the transaction leaves the committed store unchanged. Passing
184    /// it to [`StateStore::commit_render`] installs compatible state and removes
185    /// instances not reached during the successful render.
186    #[must_use]
187    pub fn begin_render(&self) -> RenderStateTransaction {
188        RenderStateTransaction {
189            instances: Rc::clone(&self.instances),
190            seen: BTreeSet::new(),
191            scope: None,
192        }
193    }
194
195    /// Start a transaction that reconciles only one component subtree.
196    /// State belonging to other window roots remains untouched on commit.
197    #[must_use]
198    pub fn begin_render_scope(&self, scope: ComponentInstancePath) -> RenderStateTransaction {
199        RenderStateTransaction {
200            instances: Rc::clone(&self.instances),
201            seen: BTreeSet::new(),
202            scope: Some(scope),
203        }
204    }
205
206    pub fn commit_render(&mut self, transaction: RenderStateTransaction) {
207        let active = transaction.seen.clone();
208        self.commit_render_batch([transaction], &active);
209    }
210
211    pub(crate) fn commit_render_batch(
212        &mut self,
213        transactions: impl IntoIterator<Item = RenderStateTransaction>,
214        active: &BTreeSet<ComponentInstancePath>,
215    ) {
216        let candidate = Rc::make_mut(&mut self.instances);
217        let mut scopes = BTreeSet::new();
218        let mut global = false;
219        for transaction in transactions {
220            for path in &transaction.seen {
221                if transaction
222                    .scope
223                    .as_ref()
224                    .is_none_or(|scope| path.is_within(scope))
225                    && let Some(state) = transaction.instances.get(path)
226                {
227                    candidate.insert(path.clone(), state.clone());
228                }
229            }
230            if let Some(scope) = transaction.scope {
231                scopes.insert(scope);
232            } else {
233                global = true;
234            }
235        }
236        let scopes = topmost_paths(&scopes).into_iter().collect::<BTreeSet<_>>();
237        candidate.retain(|path, _| {
238            let covered = global
239                || scopes
240                    .range((std::ops::Bound::Unbounded, std::ops::Bound::Included(path)))
241                    .next_back()
242                    .is_some_and(|scope| path.is_within(scope));
243            !covered || active.contains(path)
244        });
245    }
246
247    /// Remove one component subtree, including every descendant instance.
248    pub fn remove_scope(&mut self, scope: &ComponentInstancePath) {
249        Rc::make_mut(&mut self.instances).retain(|path, _| !path.is_within(scope));
250    }
251
252    /// Mount or reconcile one instance without cleaning any other path.
253    /// Used to make candidate component defaults readable during `view`.
254    ///
255    /// # Errors
256    ///
257    /// Returns invalid-schema/default errors.
258    pub fn mount_instance(
259        &mut self,
260        path: ComponentInstancePath,
261        schema: &ComponentStateSchema,
262    ) -> Result<StateReconcileReport, StateError> {
263        let report = reconcile_instance(Rc::make_mut(&mut self.instances), path, schema)?;
264        Ok(report)
265    }
266
267    #[must_use]
268    pub fn get(&self, path: &ComponentInstancePath, field: &str) -> Option<&UiValue> {
269        self.instances.get(path)?.values.get(field)
270    }
271
272    /// Update a declared field on a committed component instance.
273    ///
274    /// # Errors
275    ///
276    /// Returns [`StateError::UnknownInstance`], [`StateError::UnknownField`], or
277    /// [`StateError::InvalidValue`] when the update violates committed state.
278    pub fn set(
279        &mut self,
280        path: &ComponentInstancePath,
281        field: &str,
282        value: UiValue,
283    ) -> Result<bool, StateError> {
284        let state = Rc::make_mut(&mut self.instances)
285            .get_mut(path)
286            .ok_or_else(|| StateError::UnknownInstance(path.clone()))?;
287        let state_field = state
288            .schema
289            .field(field)
290            .ok_or_else(|| StateError::UnknownField {
291                path: path.clone(),
292                field: field.to_owned(),
293            })?;
294        state_field
295            .schema
296            .validate_ui_value(&value)
297            .map_err(|source| StateError::InvalidValue {
298                path: path.clone(),
299                field: field.to_owned(),
300                source,
301            })?;
302        if state.values.get(field) == Some(&value) {
303            return Ok(false);
304        }
305        Rc::make_mut(&mut state.values).insert(field.to_owned(), value);
306        Ok(true)
307    }
308
309    #[must_use]
310    pub fn instance_count(&self) -> usize {
311        self.instances.len()
312    }
313
314    pub(crate) fn contains_instance(&self, path: &ComponentInstancePath) -> bool {
315        self.instances.contains_key(path)
316    }
317
318    #[must_use]
319    pub fn paths(&self) -> BTreeSet<ComponentInstancePath> {
320        self.instances.keys().cloned().collect()
321    }
322
323    #[must_use]
324    pub fn is_sensitive(&self, path: &ComponentInstancePath, field: &str) -> bool {
325        self.instances
326            .get(path)
327            .and_then(|state| state.schema.field(field))
328            .is_some_and(|field| field.sensitive)
329    }
330
331    #[must_use]
332    pub fn inspect(&self) -> Vec<StateInstanceSnapshot> {
333        self.instances
334            .iter()
335            .map(|(path, state)| state_instance_snapshot(path, state))
336            .collect()
337    }
338
339    /// Inspect one component instance without cloning unrelated component state.
340    #[must_use]
341    pub fn inspect_instance(&self, path: &ComponentInstancePath) -> Option<StateInstanceSnapshot> {
342        self.instances
343            .get(path)
344            .map(|state| state_instance_snapshot(path, state))
345    }
346}
347
348fn state_instance_snapshot(
349    path: &ComponentInstancePath,
350    state: &ComponentState,
351) -> StateInstanceSnapshot {
352    StateInstanceSnapshot {
353        path: path.clone(),
354        fields: state
355            .values
356            .iter()
357            .map(|(name, value)| {
358                let sensitive = state
359                    .schema
360                    .field(name)
361                    .is_some_and(|field| field.sensitive);
362                (
363                    name.clone(),
364                    StateValueSnapshot {
365                        value: if sensitive {
366                            UiValue::String("<sensitive>".to_owned())
367                        } else {
368                            value.clone()
369                        },
370                        sensitive,
371                    },
372                )
373            })
374            .collect(),
375    }
376}
377
378#[derive(Clone, Debug, PartialEq)]
379pub struct StateInstanceSnapshot {
380    pub path: ComponentInstancePath,
381    pub fields: BTreeMap<String, StateValueSnapshot>,
382}
383
384#[derive(Clone, Debug, PartialEq)]
385pub struct StateValueSnapshot {
386    pub value: UiValue,
387    pub sensitive: bool,
388}
389
390#[derive(Clone, Debug)]
391pub struct RenderStateTransaction {
392    instances: Rc<BTreeMap<ComponentInstancePath, ComponentState>>,
393    seen: BTreeSet<ComponentInstancePath>,
394    scope: Option<ComponentInstancePath>,
395}
396
397impl RenderStateTransaction {
398    pub(crate) fn retains_root(&self) -> bool {
399        self.scope
400            .as_ref()
401            .is_some_and(|root| self.seen.contains(root))
402    }
403    /// Retain a previously mounted instance without changing its schema.
404    #[must_use]
405    pub fn retain_existing(&mut self, path: &ComponentInstancePath) -> bool {
406        if self.instances.contains_key(path) {
407            self.seen.insert(path.clone());
408            true
409        } else {
410            false
411        }
412    }
413
414    /// Mount or reconcile an instance inside this prospective render.
415    ///
416    /// # Errors
417    ///
418    /// Returns [`StateError::InvalidDefault`] if a programmatically constructed
419    /// schema contains an invalid default.
420    pub fn mount(
421        &mut self,
422        path: ComponentInstancePath,
423        schema: &ComponentStateSchema,
424    ) -> Result<StateReconcileReport, StateError> {
425        let report = reconcile_instance(Rc::make_mut(&mut self.instances), path.clone(), schema)?;
426        self.seen.insert(path);
427        Ok(report)
428    }
429}
430
431fn reconcile_instance(
432    instances: &mut BTreeMap<ComponentInstancePath, ComponentState>,
433    path: ComponentInstancePath,
434    schema: &ComponentStateSchema,
435) -> Result<StateReconcileReport, StateError> {
436    // Public serde construction can bypass `ComponentStateSchema::new`.
437    ComponentStateSchema::new(schema.fields.clone())?;
438
439    let next = match instances.get(&path) {
440        None => (
441            ComponentState {
442                schema: Rc::new(schema.clone()),
443                values: Rc::new(schema.defaults()),
444            },
445            StateReconcileReport {
446                created: true,
447                reset_fields: Vec::new(),
448            },
449        ),
450        Some(previous) => {
451            let mut reset_fields = Vec::new();
452            let values = schema
453                .fields
454                .iter()
455                .map(|(name, field)| {
456                    let value = previous
457                        .values
458                        .get(name)
459                        .filter(|value| field.schema.validate_ui_value(value).is_ok())
460                        .cloned()
461                        .unwrap_or_else(|| {
462                            if previous.values.contains_key(name) {
463                                reset_fields.push(name.clone());
464                            }
465                            field.default.clone()
466                        });
467                    (name.clone(), value)
468                })
469                .collect();
470            (
471                ComponentState {
472                    schema: Rc::new(schema.clone()),
473                    values: Rc::new(values),
474                },
475                StateReconcileReport {
476                    created: false,
477                    reset_fields,
478                },
479            )
480        }
481    };
482    instances.insert(path, next.0);
483    Ok(next.1)
484}
485
486#[derive(Clone, Debug, Default, Eq, PartialEq)]
487pub struct StateReconcileReport {
488    pub created: bool,
489    pub reset_fields: Vec<String>,
490}
491
492#[derive(Clone, Debug, Error, Eq, PartialEq)]
493pub enum StateError {
494    #[error("schema for state field `{field}` is invalid: {source}")]
495    InvalidSchema {
496        field: String,
497        source: SchemaDefinitionError,
498    },
499    #[error("default for state field `{field}` is invalid: {source}")]
500    InvalidDefault {
501        field: String,
502        source: SchemaValidationError,
503    },
504    #[error("component state instance `{0}` does not exist")]
505    UnknownInstance(ComponentInstancePath),
506    #[error("state field `{field}` is not declared for component `{path}`")]
507    UnknownField {
508        path: ComponentInstancePath,
509        field: String,
510    },
511    #[error("state value for `{path}.{field}` is invalid: {source}")]
512    InvalidValue {
513        path: ComponentInstancePath,
514        field: String,
515        source: SchemaValidationError,
516    },
517}
518
519#[cfg(test)]
520mod tests {
521    use super::*;
522
523    #[test]
524    fn scoped_candidates_preserve_latest_outside_writes_and_deletions() {
525        let first = ComponentInstancePath::root("View", "first");
526        let second = ComponentInstancePath::root("View", "second");
527        let removed = second.child("Counter", "gone");
528        let schema = ComponentStateSchema::new(BTreeMap::from([(
529            "count".into(),
530            StateField::new(ValueSchema::integer(), UiValue::Integer(7)),
531        )]))
532        .unwrap();
533        let mut states = StateStore::new();
534        let mut initial = states.begin_render();
535        for path in [&first, &second, &removed] {
536            initial.mount(path.clone(), &schema).unwrap();
537        }
538        states.commit_render(initial);
539        let mut candidate = states.begin_render_scope(first.clone());
540        candidate.mount(first.clone(), &schema).unwrap();
541        states.set(&second, "count", UiValue::Integer(9)).unwrap();
542        states.remove_scope(&removed);
543        states.commit_render(candidate);
544        assert_eq!(states.get(&second, "count"), Some(&UiValue::Integer(9)));
545        assert!(states.get(&removed, "count").is_none());
546    }
547
548    fn schema(value_schema: ValueSchema, default: UiValue) -> ComponentStateSchema {
549        ComponentStateSchema::new(BTreeMap::from([(
550            "value".to_owned(),
551            StateField::new(value_schema, default),
552        )]))
553        .unwrap()
554    }
555
556    #[test]
557    fn failed_render_does_not_change_committed_state() {
558        let path = ComponentInstancePath::root("App", "root").child("Combobox", "country");
559        let bool_schema = schema(ValueSchema::Bool, UiValue::Bool(false));
560        let mut store = StateStore::new();
561
562        let mut initial = store.begin_render();
563        initial.mount(path.clone(), &bool_schema).unwrap();
564        store.commit_render(initial);
565        store.set(&path, "value", UiValue::Bool(true)).unwrap();
566
567        let mut failed = store.begin_render();
568        failed
569            .mount(
570                path.clone(),
571                &schema(
572                    ValueSchema::enumeration(["new"]),
573                    UiValue::String("new".to_owned()),
574                ),
575            )
576            .unwrap();
577        drop(failed);
578
579        assert_eq!(store.get(&path, "value"), Some(&UiValue::Bool(true)));
580    }
581
582    #[test]
583    fn successful_render_resets_only_incompatible_fields() {
584        let path = ComponentInstancePath::root("App", "root");
585        let mut store = StateStore::new();
586        let bool_schema = schema(ValueSchema::Bool, UiValue::Bool(false));
587        let mut initial = store.begin_render();
588        initial.mount(path.clone(), &bool_schema).unwrap();
589        store.commit_render(initial);
590        store.set(&path, "value", UiValue::Bool(true)).unwrap();
591
592        let string_schema = schema(ValueSchema::string(), UiValue::String("reset".to_owned()));
593        let mut reload = store.begin_render();
594        let report = reload.mount(path.clone(), &string_schema).unwrap();
595        store.commit_render(reload);
596
597        assert_eq!(report.reset_fields, vec!["value"]);
598        assert_eq!(
599            store.get(&path, "value"),
600            Some(&UiValue::String("reset".to_owned()))
601        );
602    }
603
604    #[test]
605    fn inspect_instance_is_exact_and_redacts_sensitive_values() {
606        let selected = ComponentInstancePath::root("App", "root").child("Panel", "selected");
607        let unrelated = ComponentInstancePath::root("App", "root").child("Panel", "other");
608        let schema = ComponentStateSchema::new(BTreeMap::from([(
609            "token".to_owned(),
610            StateField::new(ValueSchema::string(), UiValue::String("secret".to_owned()))
611                .sensitive(true),
612        )]))
613        .unwrap();
614        let mut store = StateStore::new();
615        let mut render = store.begin_render();
616        render.mount(selected.clone(), &schema).unwrap();
617        render.mount(unrelated, &schema).unwrap();
618        store.commit_render(render);
619
620        let snapshot = store.inspect_instance(&selected).unwrap();
621        assert_eq!(snapshot.path, selected);
622        assert_eq!(
623            snapshot.fields["token"].value,
624            UiValue::String("<sensitive>".to_owned())
625        );
626        assert!(snapshot.fields["token"].sensitive);
627        assert!(
628            store
629                .inspect_instance(&ComponentInstancePath::root("missing", "root"))
630                .is_none()
631        );
632    }
633
634    #[test]
635    fn successful_render_cleans_unreachable_instances() {
636        let first = ComponentInstancePath::root("App", "root").child("Row", "first");
637        let second = ComponentInstancePath::root("App", "root").child("Row", "second");
638        let state_schema = schema(ValueSchema::integer(), UiValue::Integer(0));
639        let mut store = StateStore::new();
640        let mut initial = store.begin_render();
641        initial.mount(first.clone(), &state_schema).unwrap();
642        initial.mount(second, &state_schema).unwrap();
643        store.commit_render(initial);
644        assert_eq!(store.instance_count(), 2);
645
646        let mut next = store.begin_render();
647        next.mount(first, &state_schema).unwrap();
648        store.commit_render(next);
649        assert_eq!(store.instance_count(), 1);
650    }
651
652    #[test]
653    fn snapshots_share_instances_until_one_store_mutates() {
654        let path = ComponentInstancePath::root("Counter", "one");
655        let schema = schema(ValueSchema::integer(), UiValue::Integer(0));
656        let mut store = StateStore::new();
657        store.mount_instance(path.clone(), &schema).unwrap();
658        let snapshot = store.clone();
659        assert!(Rc::ptr_eq(&store.instances, &snapshot.instances));
660        store.set(&path, "value", UiValue::Integer(4)).unwrap();
661        assert!(!Rc::ptr_eq(&store.instances, &snapshot.instances));
662        assert_eq!(snapshot.get(&path, "value"), Some(&UiValue::Integer(0)));
663    }
664
665    #[test]
666    fn updates_are_schema_checked() {
667        let path = ComponentInstancePath::root("App", "root");
668        let state_schema = schema(ValueSchema::Bool, UiValue::Bool(false));
669        let mut store = StateStore::new();
670        let mut render = store.begin_render();
671        render.mount(path.clone(), &state_schema).unwrap();
672        store.commit_render(render);
673
674        assert!(matches!(
675            store.set(&path, "value", UiValue::String("no".to_owned())),
676            Err(StateError::InvalidValue { .. })
677        ));
678    }
679
680    #[test]
681    fn scoped_commit_preserves_other_window_roots_and_remove_scope_cleans_one() {
682        let state_schema = schema(ValueSchema::string(), UiValue::String("ready".to_owned()));
683        let main = ComponentInstancePath::root("App", "main");
684        let settings = ComponentInstancePath::root("App", "settings");
685        let mut store = StateStore::new();
686        let mut initial = store.begin_render();
687        initial.mount(main.clone(), &state_schema).unwrap();
688        initial.mount(settings.clone(), &state_schema).unwrap();
689        store.commit_render(initial);
690
691        let mut main_render = store.begin_render_scope(main.clone());
692        main_render.mount(main.clone(), &state_schema).unwrap();
693        store.commit_render(main_render);
694        assert!(store.get(&settings, "value").is_some());
695
696        store.remove_scope(&main);
697        assert!(store.get(&main, "value").is_none());
698        assert!(store.get(&settings, "value").is_some());
699    }
700}