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}
13
14impl FileDialog {
15    pub fn edit_component_part(
16        &mut self,
17        docs: &mut Documents,
18        store: &dyn ModelStore,
19        component_id: &str,
20    ) {
21        let source = part_source_key(docs.engine(), component_id);
22        if let Some(key) = &source {
23            match read_now(store, key) {
24                ReadNow::Ready(_) => {
25                    self.open_document(docs, store, key);
26                    if self.status.starts_with("open failed:") {
27                        docs.engine_mut().push_notice(self.status.clone());
28                    }
29                    return;
30                }
31                ReadNow::Loading => {
32                    docs.engine_mut().push_notice(format!("This part's source document '{key}' is still loading — Edit Part again in a moment"));
33                    return;
34                }
35                ReadNow::Absent => {}
36            }
37        }
38        let Some(info) = docs.engine().component_info(component_id) else {
39            docs.engine_mut()
40                .push_notice(format!("'{component_id}' is not an assembly component"));
41            return;
42        };
43        let destination = source.clone().filter(|key| {
44            let file = crate::store::file_name_of(key);
45            !key.starts_with('@')
46                && key.trim() == key
47                && !file.is_empty()
48                && file != "."
49                && file != ".."
50        });
51        self.name_buf = destination
52            .as_deref()
53            .map(model_display_name)
54            .unwrap_or(info.part_name);
55        self.status.clear();
56        self.use_folder(Purpose::Models, store);
57        self.component_part = Some(Recovery {
58            assembly_id: docs.active_id(),
59            component_id: component_id.into(),
60            source,
61            destination,
62        });
63        self.open_modal(Mode::CreateComponentPart);
64    }
65
66    fn cancel_component_part(&mut self) {
67        self.component_part = None;
68        self.open = false;
69    }
70
71    pub(super) fn show_create_component_part(
72        &mut self,
73        ctx: &egui::Context,
74        docs: &mut Documents,
75        store: &dyn ModelStore,
76    ) {
77        let mut create = false;
78        let mut cancel = false;
79        let source = self
80            .component_part
81            .as_ref()
82            .and_then(|p| p.destination.clone());
83        let modal = egui::Modal::new(egui::Id::new("brep-create-component-part")).show(ctx, |ui| {
84            ui.heading("Create part file?");
85            ui.label("The component has no available source file. Save its embedded part definition and open it in its own tab.");
86            if let Some(key) = source {
87                ui.label(format!("Source: {key}"));
88            } else {
89                ui.label(format!("Folder: {}", store.browser_location()));
90                ui.label("File name");
91                let field = ui.add(egui::TextEdit::singleline(&mut self.name_buf).hint_text("part name"));
92                self.hit("field:name", &field);
93                if self.want_focus {
94                    field.request_focus();
95                    self.want_focus = false;
96                }
97            }
98            ui.horizontal(|ui| {
99                let button = ui.button("Create part file");
100                self.hit("createpart", &button);
101                create = button.clicked();
102                let button = ui.button("Cancel");
103                self.hit("cancel", &button);
104                cancel = button.clicked();
105            });
106            if !self.status.is_empty() { ui.colored_label(ui.visuals().error_fg_color, &self.status); }
107        });
108        if cancel || modal.should_close() {
109            self.cancel_component_part();
110        } else if create {
111            if let Err(error) = self.create_component_part(docs, store) {
112                self.status = format!("Create part failed: {error}");
113            }
114        }
115    }
116
117    fn create_component_part(
118        &mut self,
119        docs: &mut Documents,
120        store: &dyn ModelStore,
121    ) -> Result<(), String> {
122        let pending = self
123            .component_part
124            .as_ref()
125            .ok_or("no component selected")?;
126        let index = docs
127            .iter()
128            .position(|doc| doc.id() == pending.assembly_id)
129            .ok_or("the assembly tab was closed")?;
130        let assembly = docs.get(index).unwrap();
131        let info = assembly
132            .engine
133            .component_info(&pending.component_id)
134            .ok_or("the component was removed")?;
135        if part_source_key(&assembly.engine, &pending.component_id) != pending.source {
136            return Err("the component source changed; cancel and Edit Part again".into());
137        }
138        // PLM revisions must be created by the PLM's New part/revision workflow.
139        // Never reinterpret a remote reference as a successful local file save.
140        if store.plm_client().is_some()
141            || pending
142                .source
143                .as_deref()
144                .is_some_and(|key| crate::plm::uses::revision_key_in(key).is_some())
145        {
146            return Err("this storage cannot create a missing PLM revision here; create or restore the part through PLM, then retry Edit Part".into());
147        }
148        let identity = match &pending.destination {
149            Some(key) => {
150                if key.starts_with('@') || key.trim().is_empty() {
151                    return Err("invalid source file identity".into());
152                }
153                key.clone()
154            }
155            None => {
156                let name = self.name_buf.trim();
157                if name.is_empty()
158                    || name.starts_with('@')
159                    || name.contains(['/', '\\'])
160                    || name == "."
161                    || name == ".."
162                {
163                    return Err("enter a valid file name without folders".into());
164                }
165                let file = crate::store::model_file_name(name);
166                let folder = store.browser_location();
167                // Native and IndexedDB explorers expose absolute folders. Flat
168                // stores keep their canonical bare document identities.
169                if std::path::Path::new(&folder).is_absolute() {
170                    sibling_identity(&format!("{}/_", folder.trim_end_matches('/')), &file)
171                } else {
172                    store.canonical_identity(&file)
173                }
174            }
175        };
176        if docs.iter().any(|doc| {
177            doc.name().is_some_and(|name| {
178                store.canonical_identity(name) == store.canonical_identity(&identity)
179            })
180        }) {
181            return Err("that part already has an open tab; cancel and open that tab".into());
182        }
183        ensure_absent(store, &identity)?;
184        let mut document: serde_json::Value =
185            serde_json::from_str(&assembly.engine.history_request_json())
186                .map_err(|e| e.to_string())?;
187        let entry = &mut document["partsLibrary"][&info.part_name];
188        let part = entry
189            .get("document")
190            .filter(|part| {
191                part.is_object()
192                    && part
193                        .get("features")
194                        .and_then(|v| v.as_array())
195                        .is_some_and(|features| !features.is_empty())
196            })
197            .ok_or(
198                "the component has no recoverable embedded part history; no blank file was created",
199            )?;
200        let contents = part.to_string();
201        // Prepare the standalone document before writing, so malformed history
202        // never leaves a saved file that cannot be opened.
203        let mut engine = docs.spawn_engine();
204        engine
205            .load_model_and_fit(&contents)
206            .map_err(|e| format!("embedded part cannot be opened: {e}"))?;
207        let mut standalone = Document::new(engine);
208        standalone.set_name(Some(identity.clone()));
209        let signature = document_signature(&contents);
210        let reconnect = entry["sourceKey"].as_str() != Some(&identity)
211            || entry["sourceSignature"].as_str() != Some(&signature);
212        if reconnect && !assembly.access().is_editable() {
213            return Err("the assembly is read-only; its source link cannot be updated".into());
214        }
215        entry["sourceKey"] = serde_json::json!(identity);
216        entry["sourceSignature"] = serde_json::json!(signature);
217        // Part preparation can take time; check again immediately before saving.
218        ensure_absent(store, &identity)?;
219        store.write(&identity, &contents)?;
220        if reconnect {
221            docs.get_mut(index).unwrap().engine.edit_document_json(&document.to_string())
222                .map_err(|e| format!("file saved at {identity}, but reconnect failed: {e}; cancel and open the saved part"))?;
223        }
224        docs.open_document(standalone);
225        self.save_generation += 1;
226        self.status = format!("created and opened {identity}");
227        self.cancel_component_part();
228        Ok(())
229    }
230}
231
232fn ensure_absent(store: &dyn ModelStore, identity: &str) -> Result<(), String> {
233    // A loading read is never permission to create or overwrite. This also
234    // catches files that appeared while the offer was open.
235    match read_now(store, identity) {
236        ReadNow::Ready(_) => {
237            return Err("the file now exists; cancel and open it (it was not overwritten)".into())
238        }
239        ReadNow::Loading => return Err("the file is still loading; retry in a moment".into()),
240        ReadNow::Absent => {}
241    }
242    #[cfg(not(target_arch = "wasm32"))]
243    {
244        let path = std::path::PathBuf::from(store.canonical_identity(identity));
245        if path.is_absolute()
246            && path
247                .try_exists()
248                .map_err(|e| format!("cannot check destination: {e}"))?
249        {
250            return Err(
251                "the destination exists but could not be read; it was not overwritten".into(),
252            );
253        }
254    }
255    Ok(())
256}
257