Skip to main content

brep_app/panels/
update_components.rs

1//! # Staleness (recompute on a cheap trigger, never per frame)
2//!
3//! The comparison reads the store once per entry — too hot to recompute on
4//! every panel draw. [`UpdateComponents::ensure_current`] caches the result
5//! keyed on `(EngineState::applied_generation, History::revision,
6//! FileDialog::save_generation, ModelStore::mutation_generation)`:
7//!
8//! * an applied history run bumps the first half (covers opening an assembly,
9//!   inserting components, an update-components refresh, constraint solves);
10//! * a successful store save bumps the third (covers Edit Part → edit → save
11//!   in the part's own tab → back to the assembly tab: the badge lights
12//!   without a restart);
13//! * ANY other write or delete through the store bumps the fourth.
14//!
15//! The fourth is not redundant with the third. `save_generation` counts what
16//! the Save dialog did; the comparison depends on what the STORE holds, and
17//! the dialog is not the only thing that writes to it. `kicad_bulk::write_part`
18//! re-imports a part onto a document name an open assembly may already
19//! reference, and the explorer's delete row (`panels::file`'s `del:` handler)
20//! takes a source away — neither touches the assembly document, so neither
21//! moved any of the other three, and the badge stayed dark until something
22//! unrelated moved. Keying on the store's own mutation counter covers every
23//! door into it by construction, including doors not written yet.
24//!
25//! Entries with an EMPTY `sourceKey` (embedded-only parts) are skipped;
26//! entries whose key has no store document are NOTED (surfaced in the header
27//! hover + a run-time notice) but never counted — they cannot be refreshed.
28//!
29//! The comparison only means "outdated" because every writer keeps the entry's
30//! `sourceSignature` equal to what the FILE holds: the insert lane hashes the
31//! file it read, [`UpdateComponents::run`] hashes the file it just pulled in,
32//! and an in-document part edit SAVES the part back to its `sourceKey` before
33//! stamping the signature (`panels::parts_library`'s write-through lane).
34//! Without that last one an in-context edit read as outdated against a file it
35//! was newer than — the badge meaning the opposite of what happened.
36
37use crate::panels::parts_library::{document_signature, refresh_library_entry};
38use crate::store::ModelStore;
39use brep_render::engine_state::{EngineState, NoticeSeverity};
40use crate::store::{ReadNow, Residency};
41use serde_json::Value;
42
43/// The shell-owned outdated-parts checker + batch refresher. One instance on
44/// the app; the constraints panel reads the count (and runs the refresh), the
45/// structure tree reads per-part flags.
46#[derive(Default)]
47pub struct UpdateComponents {
48    /// Parts-library entry names whose store content no longer matches their
49    /// `sourceSignature` — the badge count.
50    outdated: Vec<String>,
51    /// Entry names with a `sourceKey` but NO store document under it (noted,
52    /// never counted — nothing to refresh from).
53    missing: Vec<String>,
54    /// The `(applied_generation, history_revision, save_generation,
55    /// store_mutation_generation)` key the cache was computed against; `None`
56    /// forces a recompute on the next ensure.
57    checked: Option<(u64, u64, u64, u64)>,
58}
59
60impl UpdateComponents {
61    pub fn new() -> Self {
62        Self::default()
63    }
64
65    /// Recompute the outdated/missing sets when the staleness key moved (or
66    /// after [`Self::invalidate`]); otherwise a cheap compare. The shell calls
67    /// this once per frame BEFORE the assembly panels draw.
68    pub fn ensure_current(
69        &mut self,
70        state: &mut EngineState,
71        model_store: &dyn ModelStore,
72        save_generation: u64,
73    ) {
74        let key = (
75            state.applied_generation(),
76            state.history.revision(),
77            save_generation,
78            model_store.mutation_generation(),
79        );
80        if self.checked == Some(key) {
81            return;
82        }
83        let (outdated, missing) = compute(state, model_store);
84        self.outdated = outdated;
85        self.missing = missing;
86        self.checked = Some(key);
87    }
88
89    /// Drop the cache so the next [`Self::ensure_current`] recomputes even on
90    /// an unmoved key (after a run, or an external store change).
91    pub fn invalidate(&mut self) {
92        self.checked = None;
93    }
94
95    /// The outdated-entry count — the constraints-header badge number.
96    pub fn outdated_count(&self) -> usize {
97        self.outdated.len()
98    }
99
100    /// The outdated entry names — what the Diagram and PCB workbenches mark on
101    /// the components placed from them.
102    pub fn outdated(&self) -> &[String] {
103        &self.outdated
104    }
105
106    /// Whether `part_name`'s library entry is outdated — the structure tree's
107    /// per-node badge (every instance of the part lights).
108    pub fn is_outdated(&self, part_name: &str) -> bool {
109        self.outdated.iter().any(|part| part == part_name)
110    }
111
112    /// Entry names noted as source-less (header hover text).
113    pub fn missing(&self) -> &[String] {
114        &self.missing
115    }
116
117    /// RUN the update (the header button): recompute against the LIVE document
118    /// (never a stale cache), rewrite every outdated entry from its store
119    /// content through [`refresh_library_entry`], then reload ONCE — the
120    /// kernel self-heals every instance + re-solves, and the main-side sync
121    /// captures healed snapshots back. Per-entry failures (missing store key,
122    /// unreadable content) toast and SKIP — the batch never aborts. Returns
123    /// the number of entries refreshed.
124    pub fn run(
125        &mut self,
126        state: &mut EngineState,
127        model_store: &dyn ModelStore,
128    ) -> Result<usize, String> {
129        let (outdated, missing) = compute(state, model_store);
130        if !missing.is_empty() {
131            state.push_notice_as(NoticeSeverity::Warning, format!(
132                "Update components: no source document for {} — skipped",
133                missing.join(", ")
134            ));
135        }
136        if outdated.is_empty() {
137            self.invalidate();
138            return Ok(0);
139        }
140        let mut document: Value = serde_json::from_str(&state.history_request_json())
141            .map_err(|error| format!("assembly document unreadable: {error}"))?;
142        let mut refreshed = 0usize;
143        for part_name in &outdated {
144            let source_key = document["partsLibrary"][part_name]["sourceKey"]
145                .as_str()
146                .unwrap_or_default()
147                .to_string();
148            let contents = match crate::store::read_now(model_store, &source_key) {
149                ReadNow::Ready(contents) => contents,
150                ReadNow::Loading => {
151                    state.push_notice_as(NoticeSeverity::Info, format!(
152                        "Update components: '{part_name}' is still loading from the store — run the update again in a moment"
153                    ));
154                    continue;
155                }
156                ReadNow::Absent => {
157                    state.push_notice_as(NoticeSeverity::Warning, format!(
158                        "Update components: '{part_name}' source '{source_key}' missing — skipped"
159                    ));
160                    continue;
161                }
162            };
163            match refresh_library_entry(&mut document, part_name, &contents) {
164                Ok(unlinked) => {
165                    refreshed += 1;
166                    // Per-component, not per-refresh: a sheet component the
167                    // assembly can no longer place is named and the rest of the
168                    // batch still lands.
169                    for line in unlinked {
170                        state.push_notice_as(NoticeSeverity::Warning, format!("Update components: '{part_name}': {line}"));
171                    }
172                }
173                Err(error) => state.push_notice(format!(
174                    "Update components: '{part_name}': {error} — skipped"
175                )),
176            }
177        }
178        if refreshed > 0 {
179            // The ONE reload for the whole batch (no zoom — the camera stays).
180            state
181                .edit_document_json(&document.to_string())
182                .map_err(|error| format!("assembly reload failed: {error}"))?;
183            state.push_notice_as(NoticeSeverity::Info, format!(
184                "Updated {refreshed} part(s) from source — instances and eCAD snapshots refreshed"
185            ));
186        }
187        self.invalidate();
188        Ok(refreshed)
189    }
190}
191
192/// The signature comparison over the LIVE document's `partsLibrary` block →
193/// `(outdated, missing)` entry-name sets. Componentless documents short-circuit
194/// to empty (zero cost for modeling files).
195fn compute(state: &mut EngineState, model_store: &dyn ModelStore) -> (Vec<String>, Vec<String>) {
196    let mut outdated = Vec::new();
197    let mut missing = Vec::new();
198    if !state.history_has_assembly() {
199        return (outdated, missing);
200    }
201    // The document's partsLibrary block mirrors the kernel store (heals + GC
202    // included); `apply_run_output` installs and mirrors the store of any run
203    // that moved it, so the block is already current for this generation.
204    let Ok(document) = serde_json::from_str::<Value>(&state.history_request_json()) else {
205        return (outdated, missing);
206    };
207    let Some(library) = document.get("partsLibrary").and_then(Value::as_object) else {
208        return (outdated, missing);
209    };
210    for (part_name, entry) in library {
211        let Some(source_key) = entry
212            .get("sourceKey")
213            .and_then(Value::as_str)
214            .filter(|key| !key.is_empty())
215        else {
216            continue; // embedded-only part: no source to compare against
217        };
218        match crate::store::read_now(model_store, source_key) {
219            // Listed but not loaded yet (an index-hydrated store, S0): neither
220            // missing nor outdated — there is nothing to judge until the bytes
221            // land, and their landing moves the store generation this cache is
222            // keyed on, so the entry is judged then.
223            ReadNow::Loading => {}
224            ReadNow::Absent => missing.push(part_name.clone()),
225            ReadNow::Ready(contents) => {
226                let signature = entry
227                    .get("sourceSignature")
228                    .and_then(Value::as_str)
229                    .unwrap_or_default();
230                // A component's version belongs to the DEVICE it was placed
231                // from, and a nested device's key is a key of ITS OWN parent's
232                // library — the same spelling in a different namespace. Judging
233                // one against a top-level entry read it as stale against a part
234                // it was never placed from, and NOTHING could clear the badge:
235                // `parts_library::refresh_library_entry` binds by occurrence
236                // CHAIN and correctly declines to touch a device that is not
237                // this entry's. So a chain (`ACOMP9:ACOMP1`) is not this
238                // entry's to judge. A top-level occurrence is, and so is a
239                // legacy component with no occurrence at all.
240                let stale_sheet = ["diagram", "pcb"].iter().any(|block| {
241                    document[*block]["components"].as_array().is_some_and(|components| {
242                        components.iter().any(|component| component["part"]["key"].as_str() == Some(part_name.as_str())
243                            && !component["part"]["instance"].as_str().is_some_and(|id| id.contains(':'))
244                            && component["part"]["signature"].as_str() != Some(signature))
245                    })
246                });
247                if document_signature(&contents) != signature || stale_sheet {
248                    outdated.push(part_name.clone());
249                }
250            }
251        }
252    }
253    (outdated, missing)
254}
255