Skip to main content

ua_client/
engine.rs

1use std::sync::Arc;
2
3use tokio::runtime::Runtime;
4use tokio::sync::mpsc;
5
6use opcua::types::NodeId;
7
8use crate::client::{UaClient, parse_variant};
9use crate::messages::{UiAction, UiUpdate};
10use crate::model::{AppModel, ConnectionState, DetailTab, MethodCallState};
11use crate::types::{AuthSpec, EndpointInfo, ValueTree};
12
13#[derive(Debug, Clone, Copy)]
14pub enum FilePickTarget {
15    CertPath,
16    KeyPath,
17}
18
19pub trait FrontendCtx: Clone + Send + Sync + 'static {
20    fn request_repaint(&self);
21    fn set_clipboard(&self, text: &str);
22    fn pick_file(
23        &self,
24        rt: &Runtime,
25        update_tx: &mpsc::UnboundedSender<UiUpdate>,
26        target: FilePickTarget,
27        title: &str,
28        default_dir: &str,
29    );
30}
31
32pub struct Engine {
33    pub model: AppModel,
34    pub client: Arc<UaClient>,
35    pub rt: Runtime,
36    pub update_tx: mpsc::UnboundedSender<UiUpdate>,
37}
38
39impl Engine {
40    pub fn new(
41        rt: Runtime,
42        log_rx: mpsc::UnboundedReceiver<UiUpdate>,
43    ) -> (Self, mpsc::UnboundedReceiver<UiUpdate>) {
44        let (update_tx, update_rx) = mpsc::unbounded_channel();
45        forward_logs(log_rx, update_tx.clone());
46        let engine = Self {
47            model: AppModel::default(),
48            client: Arc::new(UaClient::new()),
49            rt,
50            update_tx,
51        };
52        (engine, update_rx)
53    }
54
55    pub fn apply_update<C: FrontendCtx>(&mut self, ctx: &C, update: UiUpdate) {
56        match update {
57            UiUpdate::ConnectStarted => self.model.connection = ConnectionState::Connecting,
58            UiUpdate::ConnectFinished(Ok(())) => {
59                self.model.connection = ConnectionState::Connected;
60                self.model.record_successful_connection();
61                tracing::info!("connected to {}", self.model.endpoint_url);
62                let saved = self
63                    .model
64                    .last_selection_paths
65                    .get(&self.model.endpoint_url)
66                    .cloned();
67                match saved {
68                    Some(path) if !path.is_empty() => {
69                        tracing::info!(
70                            "restoring previous selection ({} ancestors)",
71                            path.len()
72                        );
73                        self.spawn_restore_selection(ctx, path);
74                    }
75                    _ => {
76                        let root = self.model.root_node.clone();
77                        self.ensure_expanded(ctx, root);
78                    }
79                }
80            }
81            UiUpdate::ConnectFinished(Err(e)) => {
82                self.model.connection = ConnectionState::Disconnected;
83                tracing::error!("connect failed: {e}");
84            }
85            UiUpdate::DisconnectStarted => self.model.connection = ConnectionState::Disconnecting,
86            UiUpdate::DisconnectFinished => {
87                self.model.connection = ConnectionState::Disconnected;
88                self.model.reset_session_state();
89                tracing::info!("disconnected");
90            }
91            UiUpdate::ChildrenLoaded { parent, children } => {
92                self.model.tree.loading.remove(&parent);
93                match children {
94                    Ok(c) => {
95                        self.model.tree.children.insert(parent.clone(), c);
96                        self.model.tree.expanded.insert(parent);
97                    }
98                    Err(e) => tracing::error!("browse {parent} failed: {e}"),
99                }
100            }
101            UiUpdate::SummaryLoaded { node, summary } => {
102                if self.model.selected.as_ref() == Some(&node) {
103                    match summary {
104                        Ok(s) => self.model.node_summary = Some(s),
105                        Err(e) => tracing::error!("read summary {node} failed: {e}"),
106                    }
107                }
108            }
109            UiUpdate::ReferencesLoaded { node, refs } => {
110                if self.model.selected.as_ref() == Some(&node) {
111                    self.model.references_loading = false;
112                    match refs {
113                        Ok(rs) => self.model.references = Some(rs),
114                        Err(e) => tracing::error!("browse refs {node} failed: {e}"),
115                    }
116                }
117            }
118            UiUpdate::SelectionPathResolved { url, path } => {
119                self.model.last_selection_paths.insert(url, path);
120            }
121            UiUpdate::RestoreSelection(node) => {
122                self.model.selected = Some(node.clone());
123                self.spawn_node_summary(ctx, node.clone());
124                if self.model.active_tab == DetailTab::References {
125                    self.spawn_browse_references(ctx, node);
126                }
127            }
128            UiUpdate::PathReady { node, path } => match path {
129                Ok(p) => {
130                    ctx.set_clipboard(&p);
131                    tracing::info!("copied path: {p}");
132                }
133                Err(e) => tracing::error!("path for {node} failed: {e}"),
134            },
135            UiUpdate::CertPathPicked(p) => self.model.auth_cert_path = p,
136            UiUpdate::KeyPathPicked(p) => self.model.auth_key_path = p,
137            UiUpdate::FilePickerClosed => self.model.file_picker_open = false,
138            UiUpdate::EndpointsDiscovered { url, result } => {
139                if url != self.model.endpoint_url {
140                    tracing::debug!("dropping endpoints result for stale url {url}");
141                } else {
142                    self.model.endpoints_loading = false;
143                    match result {
144                        Ok(eps) => {
145                            tracing::info!("discovered {} endpoint(s)", eps.len());
146                            self.model.discovered_endpoints = Some(eps);
147                            self.select_first_matching_endpoint();
148                        }
149                        Err(e) => {
150                            tracing::error!("endpoint discovery failed: {e}");
151                            self.model.discovered_endpoints = Some(Vec::new());
152                        }
153                    }
154                }
155            }
156            UiUpdate::MethodSignatureLoaded { node, result } => {
157                if !self.method_call_targets(&node) {
158                    return;
159                }
160                match result {
161                    Ok(signature) => {
162                        let n_inputs = signature.inputs.len();
163                        self.model.method_call = Some(MethodCallState::Inputs {
164                            node,
165                            signature,
166                            edited: vec![String::new(); n_inputs],
167                            field_errors: vec![None; n_inputs],
168                            call_error: None,
169                        });
170                    }
171                    Err(error) => {
172                        tracing::error!("read method signature {node} failed: {error}");
173                        self.model.method_call =
174                            Some(MethodCallState::Failed { node, error });
175                    }
176                }
177            }
178            UiUpdate::MethodCallFinished { node, result } => {
179                if !self.method_call_targets(&node) {
180                    return;
181                }
182                let Some(MethodCallState::Calling {
183                    node, signature, edited,
184                }) = self.model.method_call.take()
185                else {
186                    return;
187                };
188                match result {
189                    Ok(outcome) => {
190                        self.model.method_call = Some(MethodCallState::Result {
191                            node,
192                            signature,
193                            edited,
194                            outcome,
195                        });
196                    }
197                    Err(error) => {
198                        tracing::error!("call method {node} failed: {error}");
199                        let n_inputs = signature.inputs.len();
200                        self.model.method_call = Some(MethodCallState::Inputs {
201                            node,
202                            signature,
203                            edited,
204                            field_errors: vec![None; n_inputs],
205                            call_error: Some(error),
206                        });
207                    }
208                }
209            }
210            UiUpdate::Log(line) => self.model.push_log(line),
211        }
212    }
213
214    fn method_call_targets(&self, node: &NodeId) -> bool {
215        self.model
216            .method_call
217            .as_ref()
218            .map(|s| s.node() == node)
219            .unwrap_or(false)
220    }
221
222    pub fn dispatch<C: FrontendCtx>(&mut self, ctx: &C, action: UiAction) {
223        match action {
224            UiAction::EndpointEdited(s) => {
225                if s != self.model.endpoint_url {
226                    self.model.endpoint_url = s;
227                    self.model.discovered_endpoints = None;
228                    self.model.selected_endpoint = None;
229                    self.model.endpoints_loading = false;
230                    self.model.apply_saved_connection_prefs();
231                }
232            }
233            UiAction::TabSelected(t) => {
234                self.model.active_tab = t;
235                if t == DetailTab::References
236                    && let Some(node) = self.model.selected.clone()
237                    && self.model.references.is_none()
238                    && !self.model.references_loading
239                {
240                    self.spawn_browse_references(ctx, node);
241                }
242            }
243            UiAction::ConnectClicked => {
244                if self.model.selected_endpoint.is_none() {
245                    tracing::info!("no endpoint selected; opening picker");
246                    self.open_endpoint_picker(ctx);
247                } else {
248                    let ep = self.model.selected_endpoint.as_ref().unwrap();
249                    tracing::info!(
250                        "connecting with {} / {}",
251                        ep.security_policy,
252                        ep.security_mode.label()
253                    );
254                    self.spawn_connect(ctx);
255                }
256            }
257            UiAction::DisconnectClicked => self.spawn_disconnect(ctx),
258            UiAction::NodeToggleExpand(n) => self.toggle_expand(ctx, n),
259            UiAction::NodeSelected(n) => self.select_node(ctx, n),
260            UiAction::ClearSelection => {
261                self.model.selected = None;
262                self.model.node_summary = None;
263                self.model.references = None;
264                self.model.references_loading = false;
265            }
266            UiAction::RefreshClicked => {
267                if let Some(node) = self.model.selected.clone() {
268                    self.spawn_node_summary(ctx, node.clone());
269                    if self.model.active_tab == DetailTab::References {
270                        self.spawn_browse_references(ctx, node);
271                    }
272                }
273            }
274            UiAction::OpenEndpointPicker => {
275                self.open_endpoint_picker(ctx);
276            }
277            UiAction::CloseEndpointPicker => {
278                self.model.endpoints_dialog_open = false;
279            }
280            UiAction::ForceRefreshEndpoints => {
281                if !self.model.endpoints_loading {
282                    self.spawn_discover_endpoints(ctx);
283                }
284            }
285            UiAction::SelectEndpoint(ep) => {
286                self.model.selected_endpoint = Some(ep);
287            }
288            UiAction::ClearSelectedEndpoint => {
289                self.model.selected_endpoint = None;
290            }
291            UiAction::SetAuthMode(mode) => self.model.auth_mode = mode,
292            UiAction::SetEndpointModeFilter(mode) => {
293                self.model.endpoint_mode_filter = mode;
294                self.select_first_matching_endpoint();
295            }
296            UiAction::AuthUsernameEdited(s) => self.model.auth_username = s,
297            UiAction::AuthPasswordEdited(s) => self.model.auth_password = s,
298            UiAction::AuthCertPathEdited(s) => self.model.auth_cert_path = s,
299            UiAction::AuthKeyPathEdited(s) => self.model.auth_key_path = s,
300            UiAction::PickAuthCertPath => {
301                if !self.model.file_picker_open {
302                    self.model.file_picker_open = true;
303                    let default_dir = self.model.auth_cert_path.clone();
304                    ctx.pick_file(
305                        &self.rt,
306                        &self.update_tx,
307                        FilePickTarget::CertPath,
308                        "Pick client certificate",
309                        &default_dir,
310                    );
311                }
312            }
313            UiAction::PickAuthKeyPath => {
314                if !self.model.file_picker_open {
315                    self.model.file_picker_open = true;
316                    let default_dir = self.model.auth_key_path.clone();
317                    ctx.pick_file(
318                        &self.rt,
319                        &self.update_tx,
320                        FilePickTarget::KeyPath,
321                        "Pick private key",
322                        &default_dir,
323                    );
324                }
325            }
326            UiAction::CopyPath(node) => self.spawn_browse_path(ctx, node),
327            UiAction::CopyNodeId(node) => {
328                let text = node.to_string();
329                ctx.set_clipboard(&text);
330                tracing::info!("copied node id: {text}");
331            }
332            UiAction::CopyNodeValue => {
333                let Some(summary) = self.model.node_summary.as_ref() else {
334                    tracing::warn!("no node summary loaded; nothing to copy");
335                    return;
336                };
337                match summary.attributes.iter().find(|a| a.name == "Value") {
338                    Some(attr) => {
339                        let text = render_value_for_clipboard(&attr.value);
340                        ctx.set_clipboard(&text);
341                        tracing::info!("copied value of {}", summary.node_id);
342                    }
343                    None => tracing::warn!(
344                        "selected node {} has no Value attribute",
345                        summary.node_id
346                    ),
347                }
348            }
349            UiAction::ConfirmConnect => {
350                if self.model.selected_endpoint.is_some() {
351                    self.model.endpoints_dialog_open = false;
352                    self.spawn_connect(ctx);
353                } else {
354                    tracing::warn!("ConfirmConnect with no endpoint selected");
355                }
356            }
357            UiAction::OpenMethodCall(node) => self.open_method_call(ctx, node),
358            UiAction::CloseMethodCall => {
359                self.model.method_call = None;
360            }
361            UiAction::MethodArgEdited { index, value } => match self.model.method_call.as_mut() {
362                Some(MethodCallState::Inputs { edited, call_error, field_errors, .. }) => {
363                    if let Some(slot) = edited.get_mut(index) {
364                        *slot = value;
365                        *call_error = None;
366                        if let Some(err_slot) = field_errors.get_mut(index) {
367                            *err_slot = None;
368                        }
369                    }
370                }
371                Some(MethodCallState::Result { edited, .. }) => {
372                    if let Some(slot) = edited.get_mut(index) {
373                        *slot = value;
374                    }
375                }
376                _ => {}
377            },
378            UiAction::CallMethodConfirmed => self.confirm_method_call(ctx),
379        }
380    }
381
382    fn open_method_call<C: FrontendCtx>(&mut self, ctx: &C, node: NodeId) {
383        self.model.method_call = Some(MethodCallState::Loading { node: node.clone() });
384        self.spawn_method_signature(ctx, node);
385    }
386
387    fn confirm_method_call<C: FrontendCtx>(&mut self, ctx: &C) {
388        let (node, signature, edited) = match self.model.method_call.as_ref() {
389            Some(MethodCallState::Inputs {
390                node, signature, edited, ..
391            })
392            | Some(MethodCallState::Result {
393                node, signature, edited, ..
394            }) => (node.clone(), signature.clone(), edited.clone()),
395            _ => return,
396        };
397
398        let mut variants = Vec::with_capacity(signature.inputs.len());
399        let mut field_errors = vec![None; signature.inputs.len()];
400        let mut any_error = false;
401        for (i, arg) in signature.inputs.iter().enumerate() {
402            let s = edited.get(i).cloned().unwrap_or_default();
403            match parse_variant(&s, &arg.data_type, arg.value_rank) {
404                Ok(v) => variants.push(v),
405                Err(e) => {
406                    field_errors[i] = Some(e);
407                    any_error = true;
408                }
409            }
410        }
411        if any_error {
412            self.model.method_call = Some(MethodCallState::Inputs {
413                node,
414                signature,
415                edited,
416                field_errors,
417                call_error: None,
418            });
419            return;
420        }
421
422        let parent = signature.parent_object.clone();
423        let method = signature.method_node.clone();
424        self.model.method_call = Some(MethodCallState::Calling {
425            node: node.clone(),
426            signature,
427            edited,
428        });
429        self.spawn_method_call(ctx, parent, method, variants, node);
430    }
431
432    fn spawn_method_signature<C: FrontendCtx>(&self, ctx: &C, node: NodeId) {
433        let client = self.client.clone();
434        let tx = self.update_tx.clone();
435        let ctx = ctx.clone();
436        self.rt.spawn(async move {
437            let result = client
438                .read_method_signature(&node)
439                .await
440                .map_err(|e| e.to_string());
441            let _ = tx.send(UiUpdate::MethodSignatureLoaded { node, result });
442            ctx.request_repaint();
443        });
444    }
445
446    fn spawn_method_call<C: FrontendCtx>(
447        &self,
448        ctx: &C,
449        parent: NodeId,
450        method: NodeId,
451        inputs: Vec<opcua::types::Variant>,
452        node: NodeId,
453    ) {
454        let client = self.client.clone();
455        let tx = self.update_tx.clone();
456        let ctx = ctx.clone();
457        self.rt.spawn(async move {
458            let result = client
459                .call_method(&parent, &method, inputs)
460                .await
461                .map_err(|e| e.to_string());
462            let _ = tx.send(UiUpdate::MethodCallFinished { node, result });
463            ctx.request_repaint();
464        });
465    }
466
467    fn toggle_expand<C: FrontendCtx>(&mut self, ctx: &C, node: NodeId) {
468        if self.model.tree.expanded.contains(&node) {
469            self.model.tree.expanded.remove(&node);
470        } else if self.model.tree.children.contains_key(&node) {
471            self.model.tree.expanded.insert(node);
472        } else if !self.model.tree.loading.contains(&node) {
473            self.model.tree.loading.insert(node.clone());
474            self.spawn_browse_children(ctx, node);
475        }
476    }
477
478    fn ensure_expanded<C: FrontendCtx>(&mut self, ctx: &C, node: NodeId) {
479        if self.model.tree.expanded.contains(&node) {
480            return;
481        }
482        if self.model.tree.children.contains_key(&node) {
483            self.model.tree.expanded.insert(node);
484        } else if !self.model.tree.loading.contains(&node) {
485            self.model.tree.loading.insert(node.clone());
486            self.spawn_browse_children(ctx, node);
487        }
488    }
489
490    fn select_node<C: FrontendCtx>(&mut self, ctx: &C, node: NodeId) {
491        self.model.selected = Some(node.clone());
492        self.model.node_summary = None;
493        self.model.references = None;
494        self.spawn_node_summary(ctx, node.clone());
495        if self.model.active_tab == DetailTab::References {
496            self.spawn_browse_references(ctx, node.clone());
497        }
498        self.spawn_resolve_path(ctx, node);
499    }
500
501    fn select_first_matching_endpoint(&mut self) {
502        if let Some(eps) = self.model.discovered_endpoints.as_ref() {
503            let mut filtered: Vec<&EndpointInfo> = eps
504                .iter()
505                .filter(|e| e.security_mode == self.model.endpoint_mode_filter)
506                .collect();
507            filtered.sort_by(|a, b| b.security_level.cmp(&a.security_level));
508            self.model.selected_endpoint = filtered.first().map(|&e| e.clone());
509        }
510    }
511
512    fn open_endpoint_picker<C: FrontendCtx>(&mut self, ctx: &C) {
513        self.model.endpoints_dialog_open = true;
514        if self.model.discovered_endpoints.is_none() && !self.model.endpoints_loading {
515            self.spawn_discover_endpoints(ctx);
516        }
517    }
518
519    fn spawn_resolve_path<C: FrontendCtx>(&self, ctx: &C, node: NodeId) {
520        let client = self.client.clone();
521        let tx = self.update_tx.clone();
522        let url = self.model.endpoint_url.clone();
523        let ctx = ctx.clone();
524        self.rt.spawn(async move {
525            match client.node_path(&node).await {
526                Ok(path) => {
527                    let _ = tx.send(UiUpdate::SelectionPathResolved { url, path });
528                    ctx.request_repaint();
529                }
530                Err(e) => tracing::debug!("node_path for {node} failed: {e}"),
531            }
532        });
533    }
534
535    pub fn navigate_to_textual_path<C: FrontendCtx>(&self, ctx: &C, path: String) {
536        let client = self.client.clone();
537        let tx = self.update_tx.clone();
538        let ctx = ctx.clone();
539        self.rt.spawn(async move {
540            let target = match client.resolve_browse_path(&path).await {
541                Ok(t) => t,
542                Err(e) => {
543                    tracing::warn!("resolve path '{path}' failed: {e}");
544                    return;
545                }
546            };
547            let chain = match client.node_path(&target).await {
548                Ok(c) => c,
549                Err(e) => {
550                    tracing::warn!("node_path for '{path}' failed: {e}");
551                    return;
552                }
553            };
554            if chain.is_empty() {
555                return;
556            }
557            let final_target = chain.last().cloned().unwrap();
558            for parent in chain.iter().take(chain.len() - 1) {
559                match client.browse_children(parent).await {
560                    Ok(children) => {
561                        let _ = tx.send(UiUpdate::ChildrenLoaded {
562                            parent: parent.clone(),
563                            children: Ok(children),
564                        });
565                    }
566                    Err(e) => {
567                        tracing::warn!("navigate: browse_children({parent}) failed: {e}");
568                        ctx.request_repaint();
569                        return;
570                    }
571                }
572            }
573            let _ = tx.send(UiUpdate::RestoreSelection(final_target));
574            ctx.request_repaint();
575        });
576    }
577
578    fn spawn_restore_selection<C: FrontendCtx>(&self, ctx: &C, path: Vec<NodeId>) {
579        let client = self.client.clone();
580        let tx = self.update_tx.clone();
581        let ctx = ctx.clone();
582        self.rt.spawn(async move {
583            if path.is_empty() {
584                return;
585            }
586            let target = path.last().cloned().unwrap();
587            for parent in path.iter().take(path.len() - 1) {
588                match client.browse_children(parent).await {
589                    Ok(children) => {
590                        let _ = tx.send(UiUpdate::ChildrenLoaded {
591                            parent: parent.clone(),
592                            children: Ok(children),
593                        });
594                    }
595                    Err(e) => {
596                        tracing::warn!("restore: browse_children({parent}) failed: {e}");
597                        ctx.request_repaint();
598                        return;
599                    }
600                }
601            }
602            let _ = tx.send(UiUpdate::RestoreSelection(target));
603            ctx.request_repaint();
604        });
605    }
606
607    fn spawn_connect<C: FrontendCtx>(&mut self, ctx: &C) {
608        let client = self.client.clone();
609        let tx = self.update_tx.clone();
610        let url = self.model.endpoint_url.clone();
611        let endpoint = self.model.selected_endpoint.clone();
612        let auth = AuthSpec {
613            mode: self.model.auth_mode,
614            username: self.model.auth_username.clone(),
615            password: self.model.auth_password.clone(),
616            cert_path: self.model.auth_cert_path.clone(),
617            key_path: self.model.auth_key_path.clone(),
618        };
619        let ctx = ctx.clone();
620        let _ = tx.send(UiUpdate::ConnectStarted);
621        self.rt.spawn(async move {
622            let r = client
623                .connect(&url, endpoint.as_ref(), &auth)
624                .await
625                .map_err(|e| e.to_string());
626            let _ = tx.send(UiUpdate::ConnectFinished(r));
627            ctx.request_repaint();
628        });
629    }
630
631    fn spawn_discover_endpoints<C: FrontendCtx>(&mut self, ctx: &C) {
632        self.model.endpoints_loading = true;
633        self.model.discovered_endpoints = None;
634        let client = self.client.clone();
635        let tx = self.update_tx.clone();
636        let url = self.model.endpoint_url.clone();
637        let ctx = ctx.clone();
638        self.rt.spawn(async move {
639            let r = client
640                .discover_endpoints(&url)
641                .await
642                .map_err(|e| e.to_string());
643            let _ = tx.send(UiUpdate::EndpointsDiscovered { url, result: r });
644            ctx.request_repaint();
645        });
646    }
647
648    fn spawn_disconnect<C: FrontendCtx>(&self, ctx: &C) {
649        let client = self.client.clone();
650        let tx = self.update_tx.clone();
651        let ctx = ctx.clone();
652        let _ = tx.send(UiUpdate::DisconnectStarted);
653        self.rt.spawn(async move {
654            if let Err(e) = client.disconnect().await {
655                tracing::warn!("disconnect: {e}");
656            }
657            let _ = tx.send(UiUpdate::DisconnectFinished);
658            ctx.request_repaint();
659        });
660    }
661
662    fn spawn_browse_children<C: FrontendCtx>(&self, ctx: &C, node: NodeId) {
663        let client = self.client.clone();
664        let tx = self.update_tx.clone();
665        let ctx = ctx.clone();
666        self.rt.spawn(async move {
667            let r = client.browse_children(&node).await.map_err(|e| e.to_string());
668            let _ = tx.send(UiUpdate::ChildrenLoaded {
669                parent: node,
670                children: r,
671            });
672            ctx.request_repaint();
673        });
674    }
675
676    fn spawn_node_summary<C: FrontendCtx>(&self, ctx: &C, node: NodeId) {
677        let client = self.client.clone();
678        let tx = self.update_tx.clone();
679        let ctx = ctx.clone();
680        self.rt.spawn(async move {
681            let r = client
682                .read_node_summary(&node)
683                .await
684                .map_err(|e| e.to_string());
685            let _ = tx.send(UiUpdate::SummaryLoaded { node, summary: r });
686            ctx.request_repaint();
687        });
688    }
689
690    fn spawn_browse_path<C: FrontendCtx>(&self, ctx: &C, node: NodeId) {
691        let client = self.client.clone();
692        let tx = self.update_tx.clone();
693        let ctx = ctx.clone();
694        self.rt.spawn(async move {
695            let r = client.browse_path(&node).await.map_err(|e| e.to_string());
696            let _ = tx.send(UiUpdate::PathReady { node, path: r });
697            ctx.request_repaint();
698        });
699    }
700
701    fn spawn_browse_references<C: FrontendCtx>(&mut self, ctx: &C, node: NodeId) {
702        self.model.references_loading = true;
703        let client = self.client.clone();
704        let tx = self.update_tx.clone();
705        let ctx = ctx.clone();
706        self.rt.spawn(async move {
707            let r = client
708                .browse_references(&node)
709                .await
710                .map_err(|e| e.to_string());
711            let _ = tx.send(UiUpdate::ReferencesLoaded { node, refs: r });
712            ctx.request_repaint();
713        });
714    }
715}
716
717fn render_value_for_clipboard(value: &ValueTree) -> String {
718    use std::fmt::Write as _;
719    fn render(v: &ValueTree, indent: usize, out: &mut String) {
720        let pad = "  ".repeat(indent);
721        match v {
722            ValueTree::Null => {
723                let _ = write!(out, "{pad}<null>");
724            }
725            ValueTree::Leaf(s) => {
726                let _ = write!(out, "{pad}{s}");
727            }
728            ValueTree::Array(items) => {
729                for (i, item) in items.iter().enumerate() {
730                    if i > 0 {
731                        out.push('\n');
732                    }
733                    let _ = write!(out, "{pad}[{i}]");
734                    out.push('\n');
735                    render(item, indent + 1, out);
736                }
737            }
738            ValueTree::Object(fields) => {
739                for (i, (k, val)) in fields.iter().enumerate() {
740                    if i > 0 {
741                        out.push('\n');
742                    }
743                    let _ = write!(out, "{pad}{k}:");
744                    out.push('\n');
745                    render(val, indent + 1, out);
746                }
747            }
748        }
749    }
750    let mut out = String::new();
751    render(value, 0, &mut out);
752    out
753}
754
755fn forward_logs(
756    mut log_rx: mpsc::UnboundedReceiver<UiUpdate>,
757    update_tx: mpsc::UnboundedSender<UiUpdate>,
758) {
759    std::thread::spawn(move || {
760        while let Some(msg) = log_rx.blocking_recv() {
761            if update_tx.send(msg).is_err() {
762                break;
763            }
764        }
765    });
766}