Skip to main content

brep_app/panels/file/
component_part.rs

1//! Recover a standalone component document without changing the active assembly
2//! until the user confirms and storage accepts the write.
3use super::*;
4use crate::panels::component_actions::part_source_key;
5use crate::store::{read_now, ReadNow};
6
7pub(super) struct Recovery {
8    assembly_id: u64,
9    component_id: String,
10    source: Option<String>,
11    destination: Option<String>,
12    /// The chosen file exists: `(that file, the free name beside it)`, and
13    /// the dialog asks which (prompts over refusals).
14    taken: Option<(String, String)>,
15    /// The user chose to overwrite this file.
16    overwrite: Option<String>,
17}
18
19impl FileDialog {
20    pub fn edit_component_part(
21        &mut self,
22        docs: &mut Documents,
23        store: &dyn ModelStore,
24        component_id: &str,
25    ) {
26        // A part inside a sub-assembly (a nested BOM line) with no file of its
27        // own: its new file is named here too.
28        if component_id.contains(':') {
29            match docs.occurrence_part(component_id) {
30                Ok((part, source)) if source.as_deref().is_none_or(str::is_empty) => {
31                    self.name_buf = part;
32                    self.status.clear();
33                    self.use_folder(Purpose::Models, store);
34                    self.component_part = Some(Recovery {
35                        assembly_id: docs.active_id(),
36                        component_id: component_id.into(),
37                        source: None,
38                        destination: None,
39                        taken: None,
40                        overwrite: None,
41                    });
42                    self.open_modal(Mode::CreateComponentPart);
43                }
44                Ok((_, Some(source))) => self.open_document(docs, store, &source),
45                Ok(_) => {}
46                Err(error) => docs.engine_mut().push_notice(format!("'{component_id}': {error}")),
47            }
48            return;
49        }
50        let source = part_source_key(docs.engine(), component_id);
51        if let Some(key) = &source {
52            match read_now(store, key) {
53                ReadNow::Ready(_) => {
54                    self.open_document(docs, store, key);
55                    if self.status.starts_with("open failed:") {
56                        docs.engine_mut().push_notice(self.status.clone());
57                    }
58                    return;
59                }
60                // Listed, not read yet (an Open takes a PLM copy that hashes
61                // to its revision without reading the revision): the read is
62                // asked for and the part opens in its tab when it lands.
63                ReadNow::Loading => {
64                    self.open_document(docs, store, key);
65                    return;
66                }
67                ReadNow::Absent => {}
68            }
69        }
70        let Some(info) = docs.engine().component_info(component_id) else {
71            docs.engine_mut()
72                .push_notice(format!("'{component_id}' is not an assembly component"));
73            return;
74        };
75        let destination = source.clone().filter(|key| {
76            let file = crate::store::file_name_of(key);
77            !key.starts_with('@')
78                && key.trim() == key
79                && !file.is_empty()
80                && file != "."
81                && file != ".."
82        });
83        self.name_buf = destination
84            .as_deref()
85            .map(model_display_name)
86            .unwrap_or(info.part_name);
87        self.status.clear();
88        self.use_folder(Purpose::Models, store);
89        self.component_part = Some(Recovery {
90            assembly_id: docs.active_id(),
91            component_id: component_id.into(),
92            source,
93            destination,
94            taken: None,
95            overwrite: None,
96        });
97        self.open_modal(Mode::CreateComponentPart);
98    }
99
100    fn cancel_component_part(&mut self) {
101        self.component_part = None;
102        self.open = false;
103    }
104
105    pub(super) fn show_create_component_part(
106        &mut self,
107        ctx: &egui::Context,
108        docs: &mut Documents,
109        store: &dyn ModelStore,
110    ) {
111        let mut create = false;
112        let mut cancel = false;
113        // Some(true): overwrite the taken file; Some(false): the free name.
114        let mut choice: Option<bool> = None;
115        let source = self
116            .component_part
117            .as_ref()
118            .and_then(|p| p.destination.clone());
119        let taken = self.component_part.as_ref().and_then(|p| p.taken.clone());
120        let modal = egui::Modal::new(egui::Id::new("brep-create-component-part")).show(ctx, |ui| {
121            ui.heading("Create part file?");
122            ui.label("The component has no available source file. Save its embedded part definition and open it in its own tab.");
123            if let Some(key) = source {
124                ui.label(format!("Source: {key}"));
125            } else {
126                ui.label(format!("Folder: {}", store.browser_location()));
127                ui.label("File name");
128                let field = ui.add(egui::TextEdit::singleline(&mut self.name_buf).hint_text("part name"));
129                self.hit("field:name", &field);
130                if self.want_focus {
131                    field.request_focus();
132                    self.want_focus = false;
133                }
134            }
135            match taken.clone() {
136                // The name is taken: ask, the free name first (Enter).
137                Some((existing, free)) => {
138                    ui.label(format!(
139                        "'{}' exists: overwrite it, or save as '{}'?",
140                        model_display_name(&existing),
141                        model_display_name(&free)
142                    ));
143                    ui.horizontal(|ui| {
144                        let button = ui.button(format!("Save as '{}'", model_display_name(&free)));
145                        self.hit("createpart:free", &button);
146                        if button.clicked() || ui.input(|i| i.key_pressed(egui::Key::Enter)) {
147                            choice = Some(false);
148                        }
149                        let button = ui.button(format!("Overwrite '{}'", model_display_name(&existing)));
150                        self.hit("createpart:overwrite", &button);
151                        if button.clicked() {
152                            choice = Some(true);
153                        }
154                        let button = ui.button("Cancel");
155                        self.hit("cancel", &button);
156                        cancel = button.clicked();
157                    });
158                }
159                None => {
160                    ui.horizontal(|ui| {
161                        let button = ui.button("Create part file");
162                        self.hit("createpart", &button);
163                        create = button.clicked();
164                        let button = ui.button("Cancel");
165                        self.hit("cancel", &button);
166                        cancel = button.clicked();
167                    });
168                }
169            }
170            if !self.status.is_empty() { ui.colored_label(ui.visuals().error_fg_color, &self.status); }
171        });
172        if let (Some(overwrite), Some((existing, free))) = (choice, taken) {
173            if let Some(pending) = self.component_part.as_mut() {
174                pending.taken = None;
175                pending.overwrite = overwrite.then(|| existing.clone());
176            }
177            if !overwrite {
178                self.name_buf = model_display_name(&free);
179            }
180            create = true;
181        }
182        if cancel || modal.should_close() {
183            self.cancel_component_part();
184        } else if create {
185            if let Err(error) = self.create_component_part(docs, store) {
186                self.status = format!("Create part failed: {error}");
187            }
188        }
189    }
190
191    fn create_component_part(
192        &mut self,
193        docs: &mut Documents,
194        store: &dyn ModelStore,
195    ) -> Result<(), String> {
196        let pending = self
197            .component_part
198            .as_ref()
199            .ok_or("no component selected")?;
200        let embedded_only = pending.source.as_deref().is_none_or(str::is_empty);
201        let overwrite = pending.overwrite.clone();
202        let index = docs
203            .iter()
204            .position(|doc| doc.id() == pending.assembly_id)
205            .ok_or("the assembly tab was closed")?;
206        // A part inside a sub-assembly (a nested BOM line): `ACOMP1:ACOMP2`, only
207        // ever offered while it has no file of its own.
208        let nested = pending.component_id.contains(':');
209        let assembly = docs.get(index).unwrap();
210        if !nested {
211            assembly
212                .engine
213                .component_info(&pending.component_id)
214                .ok_or("the component was removed")?;
215            if part_source_key(&assembly.engine, &pending.component_id) != pending.source {
216                return Err("the component source changed; cancel and Edit Part again".into());
217            }
218        }
219        // PLM revisions must be created by the PLM's New part/revision workflow.
220        // Never reinterpret a remote reference as a successful local file save.
221        if store.plm_client().is_some()
222            || pending
223                .source
224                .as_deref()
225                .is_some_and(|key| crate::plm::uses::revision_key_in(key).is_some())
226        {
227            return Err("this storage cannot create a missing PLM revision here; create or restore the part through PLM, then retry Edit Part".into());
228        }
229        let identity = match &pending.destination {
230            Some(key) => {
231                if key.starts_with('@') || key.trim().is_empty() {
232                    return Err("invalid source file identity".into());
233                }
234                key.clone()
235            }
236            None => {
237                let name = self.name_buf.trim();
238                if name.is_empty()
239                    || name.starts_with('@')
240                    || name.contains(['/', '\\'])
241                    || name == "."
242                    || name == ".."
243                {
244                    return Err("enter a valid file name without folders".into());
245                }
246                let file = crate::store::model_file_name(name);
247                let folder = store.browser_location();
248                // Native and IndexedDB explorers expose absolute folders. Flat
249                // stores keep their canonical bare document identities.
250                if std::path::Path::new(&folder).is_absolute() {
251                    sibling_identity(&format!("{}/_", folder.trim_end_matches('/')), &file)
252                } else {
253                    store.canonical_identity(&file)
254                }
255            }
256        };
257        if docs.iter().any(|doc| {
258            doc.name().is_some_and(|name| {
259                store.canonical_identity(name) == store.canonical_identity(&identity)
260            })
261        }) {
262            return Err("that part already has an open tab; cancel and open that tab".into());
263        }
264        let overwriting = overwrite.as_deref() == Some(identity.as_str());
265        if embedded_only && !overwriting && matches!(read_now(store, &identity), ReadNow::Ready(_)) {
266            let free = free_beside(store, &identity);
267            if let Some(pending) = self.component_part.as_mut() {
268                pending.taken = Some((identity, free));
269            }
270            return Ok(());
271        }
272        if !overwriting {
273            ensure_absent(store, &identity)?;
274        }
275        if nested {
276            let component_id = pending.component_id.clone();
277            docs.save_part_as(index, store, &component_id, &identity, overwriting)?;
278            self.open_document(docs, store, &identity);
279            self.save_generation += 1;
280            self.status = format!("created and opened {identity}");
281            self.cancel_component_part();
282            return Ok(());
283        }
284        let info = docs
285            .get(index)
286            .unwrap()
287            .engine
288            .component_info(&pending.component_id)
289            .ok_or("the component was removed")?;
290        let assembly = docs.get(index).unwrap();
291        let mut document: serde_json::Value =
292            serde_json::from_str(&assembly.engine.history_request_json())
293                .map_err(|e| e.to_string())?;
294        let entry = &mut document["partsLibrary"][&info.part_name];
295        let part = entry
296            .get("document")
297            .filter(|part| {
298                part.is_object()
299                    && part
300                        .get("features")
301                        .and_then(|v| v.as_array())
302                        .is_some_and(|features| !features.is_empty())
303            })
304            .ok_or(
305                "the component has no recoverable embedded part history; no blank file was created",
306            )?;
307        let contents = part.to_string();
308        // Prepare the standalone document before writing, so malformed history
309        // never leaves a saved file that cannot be opened.
310        let mut engine = docs.spawn_engine();
311        engine
312            .load_model_and_fit(&contents)
313            .map_err(|e| format!("embedded part cannot be opened: {e}"))?;
314        let mut standalone = Document::new(engine);
315        standalone.set_name(Some(identity.clone()));
316        let signature = document_signature(&contents);
317        let reconnect = entry["sourceKey"].as_str() != Some(&identity)
318            || entry["sourceSignature"].as_str() != Some(&signature);
319        if reconnect && !assembly.access().is_editable() {
320            return Err("the assembly is read-only; its source link cannot be updated".into());
321        }
322        // Part preparation can take time; check again immediately before saving.
323        if !overwriting {
324            ensure_absent(store, &identity)?;
325        }
326        if embedded_only {
327            // A part that never had a file: the Save's own promotion, under the
328            // chosen name, so every file-less copy of it (at any depth) takes
329            // the new file, not only this entry.
330            let component_id = pending.component_id.clone();
331            docs.save_part_as(index, store, &component_id, &identity, overwriting)?;
332        } else {
333            entry["sourceKey"] = serde_json::json!(identity);
334            entry["sourceSignature"] = serde_json::json!(signature);
335            store.write(&identity, &contents)?;
336            if reconnect {
337                docs.get_mut(index).unwrap().engine.edit_document_json(&document.to_string())
338                    .map_err(|e| format!("file saved at {identity}, but reconnect failed: {e}; cancel and open the saved part"))?;
339            }
340        }
341        docs.open_document(standalone);
342        self.save_generation += 1;
343        self.status = format!("created and opened {identity}");
344        self.cancel_component_part();
345        Ok(())
346    }
347}
348
349/// The first `<stem>-2`, `<stem>-3`, … beside `identity` that the store does
350/// not hold.
351fn free_beside(store: &dyn ModelStore, identity: &str) -> String {
352    let file = crate::store::file_name_of(identity);
353    let (stem, extension) = match file.rfind('.') {
354        Some(dot) if dot > 0 => (&file[..dot], &file[dot..]),
355        _ => (file.as_str(), ".nbrep"),
356    };
357    (2..)
358        .map(|n| crate::store::sibling_identity(identity, &format!("{stem}-{n}{extension}")))
359        .find(|candidate| matches!(read_now(store, candidate), ReadNow::Absent))
360        .unwrap_or_default()
361}
362
363fn ensure_absent(store: &dyn ModelStore, identity: &str) -> Result<(), String> {
364    // A loading read is never permission to create or overwrite. This also
365    // catches files that appeared while the offer was open.
366    match read_now(store, identity) {
367        ReadNow::Ready(_) => {
368            return Err("the file now exists; cancel and open it (it was not overwritten)".into())
369        }
370        ReadNow::Loading => return Err("the file is still loading; retry in a moment".into()),
371        ReadNow::Absent => {}
372    }
373    #[cfg(not(target_arch = "wasm32"))]
374    {
375        let path = std::path::PathBuf::from(store.canonical_identity(identity));
376        if path.is_absolute()
377            && path
378                .try_exists()
379                .map_err(|e| format!("cannot check destination: {e}"))?
380        {
381            return Err(
382                "the destination exists but could not be read; it was not overwritten".into(),
383            );
384        }
385    }
386    Ok(())
387}
388