Skip to main content

brep_app/document/
live_link.rs

1//! LIVE LINKS between open documents (owner, 2026-10-20): a part edited in its
2//! own tab — saved or not — and the same part edited inside an assembly are one
3//! part. Every open document that holds a source (a tab named by it, or an
4//! assembly embedding a copy of it at any depth) sees the newest state of that
5//! source when the user switches to it.
6//!
7//! The session keeps ONE current state per source key, in memory only (no
8//! file is written and nothing new is saved in a document):
9//! * LEAVING a tab PUBLISHES every source it changed since it last synced:
10//!   its own document when it has a store name, and each embedded copy with a
11//!   `sourceKey`. "Changed" is measured against what the tab last pulled or
12//!   published, or — before that — its own Open/Save baseline, so a stale copy
13//!   in an assembly nobody touched never wins over a newer part.
14//! * ENTERING a tab PULLS every source whose current state it does not show.
15//!   An assembly takes it the way a Work part edit does (every copy, at every
16//!   depth; one undo step; the assembly re-runs and its mates re-solve; lost
17//!   references are named); a part tab takes it as one undoable edit. Either
18//!   tab is then dirty.
19//!
20//! Only the active tab can change anything, so two tabs never hold two
21//! different states of one source: the one left last is the newest. Undo is
22//! per tab and is an edit like any other — an undo of a pulled change is
23//! published back on the next switch.
24use super::owned_save::{source_hash, writable};
25use super::Documents;
26use serde_json::Value;
27use std::collections::HashMap;
28
29/// The document blocks that make a document an eCAD sheet or board. Such a
30/// document takes a part's newer state like any assembly (owner,
31/// 2026-10-21: one behaviour everywhere), and says so in a notice: a pin
32/// renumbered in another tab re-pins the board, which must not be silent.
33const ECAD_BLOCKS: [&str; 2] = ["diagram", "pcb"];
34
35/// The newest state of each source, and what each tab last synced.
36#[derive(Default)]
37pub(super) struct LiveLinks {
38    /// key → (hash, document text).
39    current: HashMap<String, (u64, String)>,
40    /// (tab id, key) → the hash that tab last published or pulled.
41    seen: HashMap<(u64, String), u64>,
42    /// Tab id → its [`PublishKey`] when it last published: a tab left again
43    /// unchanged has nothing new to publish, and is not walked or hashed.
44    published: HashMap<u64, PublishKey>,
45    /// How many publishes walked a document (the work count the tests read).
46    walks: u64,
47}
48
49/// Everything [`Documents::publish_live`] reads from a tab: its document (run
50/// write-backs, edits, undo and redo, object metadata), the Open/Save baseline
51/// its copies are measured against, and the name its own copy is keyed by.
52/// What it reads from the session, the tab's `seen` hashes, is moved only by
53/// this tab's own publish and pull, and a pull that moves one forgets the key.
54#[derive(Clone, PartialEq)]
55struct PublishKey {
56    edit: (u64, u64),
57    metadata: u64,
58    baseline: u64,
59    name: Option<String>,
60}
61
62fn publish_key(doc: &super::Document) -> PublishKey {
63    PublishKey {
64        edit: doc.edit_key(),
65        metadata: doc.engine.metadata.stamp(),
66        baseline: doc.baseline_generation,
67        name: doc.name().map(str::to_string),
68    }
69}
70
71/// One source a document holds: `(key, current document, baseline hash)`.
72struct Copy {
73    /// The store's spelling of the source (the comparison key).
74    key: String,
75    /// The `sourceKey` as this document spells it.
76    raw: String,
77    document: Value,
78    baseline: Option<u64>,
79}
80
81/// Every source `current` holds as an embedded copy (first copy of each key,
82/// outermost first), with the hash of the same copy in `baseline`.
83fn embedded_copies(canon: &dyn Fn(&str) -> String, baseline: Option<&Value>, current: &Value, out: &mut Vec<Copy>) {
84    let Some(entries) = current["partsLibrary"].as_object() else {
85        return;
86    };
87    for (name, entry) in entries {
88        let base = baseline.map(|b| &b["partsLibrary"][name]).filter(|b| b.is_object());
89        let raw = entry["sourceKey"].as_str().unwrap_or("");
90        let key = canon(raw);
91        if writable(raw) && entry["document"].is_object() && !out.iter().any(|c| c.key == key) {
92            out.push(Copy {
93                raw: raw.to_string(),
94                key,
95                document: entry["document"].clone(),
96                baseline: base.map(|b| source_hash(&b["document"])),
97            });
98        }
99        embedded_copies(canon, base.map(|b| &b["document"]), &entry["document"], out);
100    }
101}
102
103/// Point every copy of `old` (compared through `canon`) at `new_key` with the
104/// body `body` and the signature `signature`, at any depth. The count changed.
105fn retarget(document: &mut Value, canon: &dyn Fn(&str) -> String, old: &str, new_key: &str, body: &Value, signature: &str) -> usize {
106    let Some(entries) = document.get_mut("partsLibrary").and_then(Value::as_object_mut) else {
107        return 0;
108    };
109    let mut changed = 0;
110    for entry in entries.values_mut() {
111        if let Some(child) = entry.get_mut("document") {
112            changed += retarget(child, canon, old, new_key, body, signature);
113        }
114        let key = entry["sourceKey"].as_str().unwrap_or("");
115        if writable(key) && canon(key) == canon(old) {
116            entry["sourceKey"] = Value::String(new_key.to_string());
117            entry["sourceSignature"] = Value::String(signature.to_string());
118            entry["document"] = body.clone();
119            changed += 1;
120        }
121    }
122    changed
123}
124
125impl Documents {
126    /// The open tabs other than `except` whose documents place a copy of
127    /// `key` at any depth: `(tab id, title)`. A part tab saved under a new
128    /// name asks whether these should follow it.
129    pub fn tabs_holding(&self, key: &str, except: u64) -> Vec<(u64, String)> {
130        let canon = self.canon.clone();
131        self.open
132            .iter()
133            .filter(|doc| doc.id() != except)
134            .filter(|doc| {
135                let mut document: Value = serde_json::from_str(&doc.engine.history_request_json()).unwrap_or(Value::Null);
136                retarget(&mut document, &*canon, key, "", &Value::Null, "") > 0
137            })
138            .map(|doc| (doc.id(), doc.title()))
139            .collect()
140    }
141
142    /// Point every copy of `old` in tab `id` at the document `text` saved as
143    /// `new_key`: one undoable edit, after which the tab is unsaved and its
144    /// next Save writes the new reference. The number of copies moved.
145    pub fn retarget_copies(&mut self, id: u64, old: &str, new_key: &str, text: &str) -> Result<usize, String> {
146        let canon = self.canon.clone();
147        let doc = self.open.iter_mut().find(|doc| doc.id() == id).ok_or("that tab is closed")?;
148        let mut document: Value = serde_json::from_str(&doc.engine.history_request_json()).map_err(|e| e.to_string())?;
149        let body: Value = serde_json::from_str(text).map_err(|e| e.to_string())?;
150        let signature = crate::panels::parts_library::document_signature(text);
151        let moved = retarget(&mut document, &*canon, old, new_key, &body, &signature);
152        if moved > 0 {
153            doc.engine.edit_document_json(&document.to_string())?;
154        }
155        Ok(moved)
156    }
157}
158
159impl Documents {
160    /// The sources tab `index` holds: its own document (when it has a store
161    /// name) and its embedded copies.
162    fn sources_of(&self, index: usize) -> (Vec<Copy>, Value) {
163        let doc = &self.open[index];
164        let current: Value = serde_json::from_str(&doc.engine.history_request_json()).unwrap_or(Value::Null);
165        let mut baseline: Option<Value> = doc.saved_signature.as_deref().and_then(|s| serde_json::from_str(s).ok());
166        // The baseline is the SAVED form, which carries `metadata` beside the
167        // request document; the request document does not.
168        if let Some(object) = baseline.as_mut().and_then(Value::as_object_mut) {
169            if current.get("metadata").is_none() {
170                object.remove("metadata");
171            }
172        }
173        let mut copies = Vec::new();
174        if let Some(name) = doc.name().filter(|name| writable(name)) {
175            copies.push(Copy {
176                key: (self.canon)(name),
177                raw: name.to_string(),
178                document: current.clone(),
179                baseline: baseline.as_ref().map(source_hash),
180            });
181        }
182        embedded_copies(self.canon.as_ref(), baseline.as_ref(), &current, &mut copies);
183        (copies, current)
184    }
185
186    /// Publish every source tab `index` changed since it last synced.
187    pub(super) fn publish_live(&mut self, index: usize) {
188        if index >= self.open.len() {
189            return;
190        }
191        let id = self.open[index].id();
192        let key = publish_key(&self.open[index]);
193        if self.live.published.get(&id) == Some(&key) {
194            return;
195        }
196        // A tab that never synced measures every copy against its baseline;
197        // while it still IS that baseline, nothing differs.
198        if self.open[index].is_its_baseline() && !self.live.seen.keys().any(|(tab, _)| *tab == id) {
199            self.live.published.insert(id, key);
200            return;
201        }
202        self.live.walks += 1;
203        let (copies, _) = self.sources_of(index);
204        self.live.published.insert(id, key);
205        for copy in copies {
206            let hash = source_hash(&copy.document);
207            let reference = self.live.seen.get(&(id, copy.key.clone())).copied().or(copy.baseline);
208            if reference != Some(hash) {
209                self.live.current.insert(copy.key.clone(), (hash, copy.document.to_string()));
210                self.live.seen.insert((id, copy.key), hash);
211            }
212        }
213    }
214
215    /// Bring tab `index` up to the current state of every source it holds.
216    pub(super) fn pull_live(&mut self, index: usize) {
217        if index >= self.open.len() || self.live.current.is_empty() {
218            return;
219        }
220        let id = self.open[index].id();
221        let own = self.open[index].name().map(|name| (self.canon)(name));
222        let (copies, mut document) = self.sources_of(index);
223        let ecad = ECAD_BLOCKS.iter().any(|key| document.get(*key).is_some_and(|block| !block.is_null()));
224        let mut pulled_parts = Vec::new();
225        let mut own_state = None;
226        let mut embedded_changed = false;
227        let mut notes = Vec::new();
228        for copy in copies {
229            let Some((hash, text)) = self.live.current.get(&copy.key).cloned() else {
230                continue;
231            };
232            let is_own = own.as_deref() == Some(copy.key.as_str());
233            // A pull that moves this tab's `seen` walks its next publish.
234            if self.live.seen.insert((id, copy.key.clone()), hash) != Some(hash) {
235                self.live.published.remove(&id);
236            }
237            if source_hash(&copy.document) == hash {
238                continue;
239            }
240            if is_own {
241                own_state = Some(text);
242                continue;
243            }
244            pulled_parts.push(crate::store::model_display_name(&copy.raw));
245            let Ok(state) = serde_json::from_str::<Value>(&text) else {
246                continue;
247            };
248            match super::work_part::refresh_parts(&mut document, &[], Some(&copy.raw), &state, &mut notes) {
249                Ok(changed) => embedded_changed |= changed,
250                Err(error) => notes.push(format!("'{}' not updated: {error}", copy.key)),
251            }
252        }
253        let engine = &mut self.open[index].engine;
254        // A tab IS the source it is named by; its own state, when newer, is
255        // the whole document (its embedded copies come with it).
256        let result = match (own_state, embedded_changed) {
257            (Some(text), _) => engine.edit_document_json(&text).map(drop),
258            (None, true) => engine.edit_document_json(&document.to_string()).map(drop),
259            (None, false) => Ok(()),
260        };
261        if let Err(error) = result {
262            engine.push_notice(format!("Linked documents: could not take the newer state: {error}"));
263        }
264        if ecad && !pulled_parts.is_empty() {
265            // A board or sheet changed by an edit made in another tab: never
266            // silently. The refresh notes name what it carried over (point
267            // renames by position) and what it could not keep bound.
268            engine.push_notice_as(
269                brep_render::engine_state::NoticeSeverity::Warning,
270                format!(
271                    "Linked documents: this board took the newer {} from its open tab{} — Undo takes it back",
272                    pulled_parts.join(", "),
273                    if notes.is_empty() { String::new() } else { format!(": {}", notes.join("; ")) }
274                ),
275            );
276            notes.clear();
277        }
278        for note in notes {
279            engine.push_notice(format!("Linked documents: {note}"));
280        }
281    }
282
283    /// Forget what a closed tab synced.
284    pub(super) fn forget_live(&mut self, id: u64) {
285        self.live.seen.retain(|(tab, _), _| *tab != id);
286        self.live.published.remove(&id);
287    }
288
289    /// A new canonicalizer can spell a source key differently: walk every tab
290    /// on its next publish.
291    pub(super) fn forget_published(&mut self) {
292        self.live.published.clear();
293    }
294}
295