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