Skip to main content

gpui_rhai/
store.rs

1use std::collections::{BTreeMap, BTreeSet};
2use std::rc::Rc;
3
4use serde::{Deserialize, Serialize};
5use thiserror::Error;
6
7use crate::{
8    ComponentInstancePath, ComponentStateSchema, SchemaValidationError, UiValue, UiValuePath,
9    UiValuePathError,
10};
11
12#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize, Deserialize)]
13#[serde(tag = "scope", content = "id", rename_all = "snake_case")]
14pub enum StoreScope {
15    App,
16    Window(String),
17}
18
19#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize, Deserialize)]
20pub struct StoreId {
21    pub scope: StoreScope,
22    pub name: String,
23}
24
25impl StoreId {
26    #[must_use]
27    pub fn app(name: impl Into<String>) -> Self {
28        Self {
29            scope: StoreScope::App,
30            name: name.into(),
31        }
32    }
33
34    #[must_use]
35    pub fn window(window: impl Into<String>, name: impl Into<String>) -> Self {
36        Self {
37            scope: StoreScope::Window(window.into()),
38            name: name.into(),
39        }
40    }
41}
42
43#[derive(Clone, Debug, Default)]
44pub struct StoreRegistry {
45    stores: Rc<BTreeMap<StoreId, StoreState>>,
46    readers: Rc<BTreeMap<StoreField, BTreeSet<ComponentInstancePath>>>,
47}
48
49#[derive(Clone, Debug)]
50struct StoreState {
51    schema: Rc<ComponentStateSchema>,
52    values: Rc<BTreeMap<String, UiValue>>,
53}
54
55#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
56struct StoreField {
57    store: StoreId,
58    field: String,
59    path: Option<UiValuePath>,
60}
61
62impl StoreRegistry {
63    #[must_use]
64    pub fn new() -> Self {
65        Self::default()
66    }
67
68    /// Declare an application- or window-scoped typed store.
69    ///
70    /// # Errors
71    ///
72    /// Returns [`StoreError::DuplicateStore`] when the ID already exists.
73    pub fn declare(&mut self, id: StoreId, schema: ComponentStateSchema) -> Result<(), StoreError> {
74        if self.stores.contains_key(&id) {
75            return Err(StoreError::DuplicateStore(id));
76        }
77        ComponentStateSchema::new(schema.fields().clone())?;
78        let values = schema
79            .fields()
80            .iter()
81            .map(|(name, field)| (name.clone(), field.default.clone()))
82            .collect();
83        Rc::make_mut(&mut self.stores).insert(
84            id,
85            StoreState {
86                schema: Rc::new(schema),
87                values: Rc::new(values),
88            },
89        );
90        Ok(())
91    }
92
93    /// Begin tracking reads for one component render.
94    ///
95    /// Previous subscriptions for the component are removed first so
96    /// conditional reads cannot leave stale invalidation edges.
97    pub fn begin_read<'a>(&'a mut self, reader: &'a ComponentInstancePath) -> StoreReadSession<'a> {
98        self.reset_reader(reader);
99        StoreReadSession {
100            registry: self,
101            reader,
102        }
103    }
104
105    pub fn reset_reader(&mut self, reader: &ComponentInstancePath) {
106        let tracked = Rc::make_mut(&mut self.readers);
107        for readers in tracked.values_mut() {
108            readers.remove(reader);
109        }
110        tracked.retain(|_, readers| !readers.is_empty());
111    }
112
113    /// Remove stale reader edges inside one successfully reconciled subtree.
114    pub fn retain_reader_scope(
115        &mut self,
116        root: &ComponentInstancePath,
117        active: &BTreeSet<ComponentInstancePath>,
118    ) {
119        let tracked = Rc::make_mut(&mut self.readers);
120        for readers in tracked.values_mut() {
121            readers.retain(|reader| {
122                !reader.is_within(root) || reader == root || active.contains(reader)
123            });
124        }
125        tracked.retain(|_, readers| !readers.is_empty());
126    }
127
128    /// Read a field and add one dependency without clearing earlier reads.
129    ///
130    /// # Errors
131    ///
132    /// Returns [`StoreError::UnknownStore`] or [`StoreError::UnknownField`].
133    pub fn read_tracked(
134        &mut self,
135        reader: &ComponentInstancePath,
136        store: &StoreId,
137        field: &str,
138    ) -> Result<UiValue, StoreError> {
139        let value = self
140            .stores
141            .get(store)
142            .ok_or_else(|| StoreError::UnknownStore(store.clone()))?
143            .values
144            .get(field)
145            .cloned()
146            .ok_or_else(|| StoreError::UnknownField {
147                store: store.clone(),
148                field: field.to_owned(),
149            })?;
150        Rc::make_mut(&mut self.readers)
151            .entry(StoreField {
152                store: store.clone(),
153                field: field.to_owned(),
154                path: None,
155            })
156            .or_default()
157            .insert(reader.clone());
158        Ok(value)
159    }
160
161    /// Read one nested path and subscribe only to that value.
162    ///
163    /// # Errors
164    ///
165    /// Returns store/field errors or a precise [`UiValuePathError`].
166    pub fn read_path_tracked(
167        &mut self,
168        reader: &ComponentInstancePath,
169        store: &StoreId,
170        field: &str,
171        path: &UiValuePath,
172    ) -> Result<UiValue, StoreError> {
173        let value = self
174            .stores
175            .get(store)
176            .ok_or_else(|| StoreError::UnknownStore(store.clone()))?
177            .values
178            .get(field)
179            .ok_or_else(|| StoreError::UnknownField {
180                store: store.clone(),
181                field: field.to_owned(),
182            })?
183            .get_path(path)?
184            .clone();
185        Rc::make_mut(&mut self.readers)
186            .entry(StoreField {
187                store: store.clone(),
188                field: field.to_owned(),
189                path: Some(path.clone()),
190            })
191            .or_default()
192            .insert(reader.clone());
193        Ok(value)
194    }
195
196    /// Validate and update a store field, returning only subscribed components.
197    ///
198    /// # Errors
199    ///
200    /// Returns [`StoreError::UnknownStore`], [`StoreError::UnknownField`], or
201    /// [`StoreError::InvalidValue`].
202    pub fn write(
203        &mut self,
204        store: &StoreId,
205        field: &str,
206        value: UiValue,
207    ) -> Result<BTreeSet<ComponentInstancePath>, StoreError> {
208        let state = self
209            .stores
210            .get(store)
211            .ok_or_else(|| StoreError::UnknownStore(store.clone()))?;
212        let declared = state
213            .schema
214            .field(field)
215            .ok_or_else(|| StoreError::UnknownField {
216                store: store.clone(),
217                field: field.to_owned(),
218            })?;
219        declared
220            .schema
221            .validate_ui_value(&value)
222            .map_err(|source| StoreError::InvalidValue {
223                store: store.clone(),
224                field: field.to_owned(),
225                source,
226            })?;
227
228        let previous =
229            state
230                .values
231                .get(field)
232                .cloned()
233                .ok_or_else(|| StoreError::UnknownField {
234                    store: store.clone(),
235                    field: field.to_owned(),
236                })?;
237        if previous == value {
238            return Ok(BTreeSet::new());
239        }
240        let invalidated = self
241            .readers
242            .iter()
243            .filter(|(read, _)| read.store == *store && read.field == field)
244            .filter(|(read, _)| {
245                read.path
246                    .as_ref()
247                    .is_none_or(|path| previous.get_path(path).ok() != value.get_path(path).ok())
248            })
249            .flat_map(|(_, readers)| readers.iter().cloned())
250            .collect();
251        let state = Rc::make_mut(&mut self.stores)
252            .get_mut(store)
253            .ok_or_else(|| StoreError::UnknownStore(store.clone()))?;
254        Rc::make_mut(&mut state.values).insert(field.to_owned(), value);
255        Ok(invalidated)
256    }
257
258    /// Replace one existing nested store path and return affected readers.
259    ///
260    /// # Errors
261    ///
262    /// Returns store/field/schema errors or a precise path error.
263    pub fn write_path(
264        &mut self,
265        store: &StoreId,
266        field: &str,
267        path: &UiValuePath,
268        value: UiValue,
269    ) -> Result<BTreeSet<ComponentInstancePath>, StoreError> {
270        let mut root = self
271            .stores
272            .get(store)
273            .ok_or_else(|| StoreError::UnknownStore(store.clone()))?
274            .values
275            .get(field)
276            .cloned()
277            .ok_or_else(|| StoreError::UnknownField {
278                store: store.clone(),
279                field: field.to_owned(),
280            })?;
281        root.set_path(path, value)?;
282        self.write(store, field, root)
283    }
284
285    /// Remove a window and all stores/read dependencies scoped to it.
286    pub fn remove_window(&mut self, window: &str) {
287        Rc::make_mut(&mut self.stores)
288            .retain(|id, _| !matches!(&id.scope, StoreScope::Window(id) if id == window));
289        Rc::make_mut(&mut self.readers).retain(
290            |field, _| !matches!(&field.store.scope, StoreScope::Window(id) if id == window),
291        );
292    }
293
294    #[must_use]
295    pub fn is_sensitive(&self, store: &StoreId, field: &str) -> bool {
296        self.stores
297            .get(store)
298            .and_then(|state| state.schema.field(field))
299            .is_some_and(|field| field.sensitive)
300    }
301
302    #[must_use]
303    pub fn inspect(&self) -> Vec<StoreSnapshot> {
304        self.stores
305            .iter()
306            .map(|(id, state)| StoreSnapshot {
307                id: id.clone(),
308                fields: state
309                    .values
310                    .iter()
311                    .map(|(name, value)| {
312                        let sensitive = state
313                            .schema
314                            .field(name)
315                            .is_some_and(|field| field.sensitive);
316                        (
317                            name.clone(),
318                            crate::StateValueSnapshot {
319                                value: if sensitive {
320                                    UiValue::String("<sensitive>".to_owned())
321                                } else {
322                                    value.clone()
323                                },
324                                sensitive,
325                            },
326                        )
327                    })
328                    .collect(),
329            })
330            .collect()
331    }
332}
333
334#[derive(Clone, Debug, PartialEq)]
335pub struct StoreSnapshot {
336    pub id: StoreId,
337    pub fields: BTreeMap<String, crate::StateValueSnapshot>,
338}
339
340pub struct StoreReadSession<'a> {
341    registry: &'a mut StoreRegistry,
342    reader: &'a ComponentInstancePath,
343}
344
345impl StoreReadSession<'_> {
346    /// Read a field and subscribe the current component to that exact field.
347    ///
348    /// # Errors
349    ///
350    /// Returns [`StoreError::UnknownStore`] or [`StoreError::UnknownField`].
351    pub fn read(&mut self, store: &StoreId, field: &str) -> Result<UiValue, StoreError> {
352        self.registry.read_tracked(self.reader, store, field)
353    }
354
355    /// Read and subscribe to one exact nested path.
356    ///
357    /// # Errors
358    ///
359    /// Returns store/field or path errors.
360    pub fn read_path(
361        &mut self,
362        store: &StoreId,
363        field: &str,
364        path: &UiValuePath,
365    ) -> Result<UiValue, StoreError> {
366        self.registry
367            .read_path_tracked(self.reader, store, field, path)
368    }
369}
370
371#[derive(Clone, Debug, Error, Eq, PartialEq)]
372pub enum StoreError {
373    #[error("store `{0:?}` is already declared")]
374    DuplicateStore(StoreId),
375    #[error("store `{0:?}` is not declared")]
376    UnknownStore(StoreId),
377    #[error("field `{field}` is not declared in store `{store:?}`")]
378    UnknownField { store: StoreId, field: String },
379    #[error("value for `{store:?}.{field}` is invalid: {source}")]
380    InvalidValue {
381        store: StoreId,
382        field: String,
383        source: SchemaValidationError,
384    },
385    #[error(transparent)]
386    State(#[from] crate::StateError),
387    #[error(transparent)]
388    Path(#[from] UiValuePathError),
389}
390
391#[cfg(test)]
392mod tests {
393    use super::*;
394    use crate::{StateField, ValueSchema};
395
396    fn session_schema() -> ComponentStateSchema {
397        ComponentStateSchema::new(BTreeMap::from([
398            (
399                "project".to_owned(),
400                StateField::new(ValueSchema::string(), UiValue::String("alpha".to_owned())),
401            ),
402            (
403                "sidebar_open".to_owned(),
404                StateField::new(ValueSchema::Bool, UiValue::Bool(true)),
405            ),
406        ]))
407        .unwrap()
408    }
409
410    fn nested_schema() -> ComponentStateSchema {
411        let row = |id: &str, label: &str| {
412            UiValue::Map(BTreeMap::from([
413                ("id".to_owned(), UiValue::String(id.to_owned())),
414                ("label".to_owned(), UiValue::String(label.to_owned())),
415            ]))
416        };
417        ComponentStateSchema::new(BTreeMap::from([(
418            "data".to_owned(),
419            StateField::new(
420                ValueSchema::UiValue,
421                UiValue::Map(BTreeMap::from([
422                    (
423                        "profile".to_owned(),
424                        UiValue::Map(BTreeMap::from([
425                            ("name".to_owned(), UiValue::String("Ada".to_owned())),
426                            ("avatar".to_owned(), UiValue::String("ada.png".to_owned())),
427                        ])),
428                    ),
429                    (
430                        "rows".to_owned(),
431                        UiValue::Array(vec![row("alpha", "Alpha"), row("beta", "Beta")]),
432                    ),
433                ])),
434            ),
435        )]))
436        .unwrap()
437    }
438
439    #[test]
440    fn writes_invalidate_only_exact_field_readers() {
441        let store_id = StoreId::app("session");
442        let project = ComponentInstancePath::root("ProjectView", "project");
443        let sidebar = ComponentInstancePath::root("Sidebar", "sidebar");
444        let mut stores = StoreRegistry::new();
445        stores.declare(store_id.clone(), session_schema()).unwrap();
446
447        stores
448            .begin_read(&project)
449            .read(&store_id, "project")
450            .unwrap();
451        stores
452            .begin_read(&sidebar)
453            .read(&store_id, "sidebar_open")
454            .unwrap();
455
456        let invalidated = stores
457            .write(&store_id, "project", UiValue::String("beta".to_owned()))
458            .unwrap();
459        assert_eq!(invalidated, BTreeSet::from([project]));
460    }
461
462    #[test]
463    fn deserialized_schema_defaults_are_validated_before_store_declaration() {
464        let schema: ComponentStateSchema = serde_json::from_str(
465            r#"{"fields":{"count":{"schema":{"type":"integer"},"default":{"type":"string","value":"wrong"}}}}"#,
466        )
467        .unwrap();
468        let mut stores = StoreRegistry::new();
469        assert!(matches!(
470            stores.declare(StoreId::app("invalid"), schema),
471            Err(StoreError::State(crate::StateError::InvalidDefault { .. }))
472        ));
473    }
474
475    #[test]
476    fn snapshots_share_large_store_values_until_a_write() {
477        let id = StoreId::app("session");
478        let mut stores = StoreRegistry::new();
479        stores.declare(id.clone(), session_schema()).unwrap();
480        let snapshot = stores.clone();
481        assert!(Rc::ptr_eq(&stores.stores, &snapshot.stores));
482        stores
483            .write(&id, "sidebar_open", UiValue::Bool(false))
484            .unwrap();
485        assert!(!Rc::ptr_eq(&stores.stores, &snapshot.stores));
486        assert_eq!(
487            snapshot.stores[&id].values["sidebar_open"],
488            UiValue::Bool(true)
489        );
490    }
491
492    #[test]
493    fn repeated_value_does_not_invalidate() {
494        let store_id = StoreId::app("session");
495        let reader = ComponentInstancePath::root("App", "root");
496        let mut stores = StoreRegistry::new();
497        stores.declare(store_id.clone(), session_schema()).unwrap();
498        stores
499            .begin_read(&reader)
500            .read(&store_id, "project")
501            .unwrap();
502
503        assert!(
504            stores
505                .write(&store_id, "project", UiValue::String("alpha".to_owned()),)
506                .unwrap()
507                .is_empty()
508        );
509    }
510
511    #[test]
512    fn removing_window_clears_window_store() {
513        let store_id = StoreId::window("settings", "draft");
514        let mut stores = StoreRegistry::new();
515        stores.declare(store_id.clone(), session_schema()).unwrap();
516        stores.remove_window("settings");
517        assert!(matches!(
518            stores.write(&store_id, "sidebar_open", UiValue::Bool(false)),
519            Err(StoreError::UnknownStore(_))
520        ));
521    }
522
523    #[test]
524    fn successful_subtree_reconcile_removes_stale_app_store_readers() {
525        let id = StoreId::app("shared");
526        let root = ComponentInstancePath::root("App", "main");
527        let removed = root.child("Panel", "removed");
528        let mut stores = StoreRegistry::new();
529        stores.declare(id.clone(), session_schema()).unwrap();
530        stores.read_tracked(&removed, &id, "sidebar_open").unwrap();
531        stores.retain_reader_scope(&root, &BTreeSet::new());
532        assert!(
533            stores
534                .write(&id, "sidebar_open", UiValue::Bool(false))
535                .unwrap()
536                .is_empty()
537        );
538    }
539
540    #[test]
541    fn nested_writes_invalidate_only_changed_path_and_whole_readers() {
542        let store_id = StoreId::app("model");
543        let whole = ComponentInstancePath::root("Whole", "whole");
544        let name = ComponentInstancePath::root("Name", "name");
545        let avatar = ComponentInstancePath::root("Avatar", "avatar");
546        let mut stores = StoreRegistry::new();
547        stores.declare(store_id.clone(), nested_schema()).unwrap();
548        let name_path = UiValuePath::new(vec![
549            crate::UiValuePathSegment::Key("profile".to_owned()),
550            crate::UiValuePathSegment::Key("name".to_owned()),
551        ])
552        .unwrap();
553        let avatar_path = UiValuePath::new(vec![
554            crate::UiValuePathSegment::Key("profile".to_owned()),
555            crate::UiValuePathSegment::Key("avatar".to_owned()),
556        ])
557        .unwrap();
558        stores.read_tracked(&whole, &store_id, "data").unwrap();
559        stores
560            .read_path_tracked(&name, &store_id, "data", &name_path)
561            .unwrap();
562        stores
563            .read_path_tracked(&avatar, &store_id, "data", &avatar_path)
564            .unwrap();
565
566        assert_eq!(
567            stores
568                .write_path(
569                    &store_id,
570                    "data",
571                    &name_path,
572                    UiValue::String("Grace".to_owned()),
573                )
574                .unwrap(),
575            BTreeSet::from([whole, name])
576        );
577        assert!(
578            stores
579                .write_path(
580                    &store_id,
581                    "data",
582                    &name_path,
583                    UiValue::String("Grace".to_owned()),
584                )
585                .unwrap()
586                .is_empty()
587        );
588    }
589
590    #[test]
591    fn keyed_item_reader_ignores_reorder_but_observes_item_change() {
592        let store_id = StoreId::app("model");
593        let reader = ComponentInstancePath::root("Row", "beta");
594        let whole = ComponentInstancePath::root("Whole", "whole");
595        let mut stores = StoreRegistry::new();
596        stores.declare(store_id.clone(), nested_schema()).unwrap();
597        let row_path = UiValuePath::new(vec![
598            crate::UiValuePathSegment::Key("rows".to_owned()),
599            crate::UiValuePathSegment::Item {
600                key_field: "id".to_owned(),
601                key: "beta".to_owned(),
602            },
603            crate::UiValuePathSegment::Key("label".to_owned()),
604        ])
605        .unwrap();
606        stores
607            .read_path_tracked(&reader, &store_id, "data", &row_path)
608            .unwrap();
609        stores.read_tracked(&whole, &store_id, "data").unwrap();
610
611        let mut reordered = stores
612            .stores
613            .get(&store_id)
614            .unwrap()
615            .values
616            .get("data")
617            .unwrap()
618            .clone();
619        if let UiValue::Map(root) = &mut reordered
620            && let Some(UiValue::Array(rows)) = root.get_mut("rows")
621        {
622            rows.reverse();
623        }
624        assert_eq!(
625            stores.write(&store_id, "data", reordered).unwrap(),
626            BTreeSet::from([whole.clone()])
627        );
628        assert_eq!(
629            stores
630                .write_path(
631                    &store_id,
632                    "data",
633                    &row_path,
634                    UiValue::String("Updated".to_owned()),
635                )
636                .unwrap(),
637            BTreeSet::from([whole, reader])
638        );
639    }
640}