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                }
231            }
232            UiAction::TabSelected(t) => {
233                self.model.active_tab = t;
234                if t == DetailTab::References
235                    && let Some(node) = self.model.selected.clone()
236                    && self.model.references.is_none()
237                    && !self.model.references_loading
238                {
239                    self.spawn_browse_references(ctx, node);
240                }
241            }
242            UiAction::ConnectClicked => {
243                if self.model.selected_endpoint.is_none() {
244                    tracing::info!("no endpoint selected; opening picker");
245                    self.open_endpoint_picker(ctx);
246                } else {
247                    let ep = self.model.selected_endpoint.as_ref().unwrap();
248                    tracing::info!(
249                        "connecting with {} / {}",
250                        ep.security_policy,
251                        ep.security_mode.label()
252                    );
253                    self.spawn_connect(ctx);
254                }
255            }
256            UiAction::DisconnectClicked => self.spawn_disconnect(ctx),
257            UiAction::NodeToggleExpand(n) => self.toggle_expand(ctx, n),
258            UiAction::NodeSelected(n) => self.select_node(ctx, n),
259            UiAction::ClearSelection => {
260                self.model.selected = None;
261                self.model.node_summary = None;
262                self.model.references = None;
263                self.model.references_loading = false;
264            }
265            UiAction::RefreshClicked => {
266                if let Some(node) = self.model.selected.clone() {
267                    self.spawn_node_summary(ctx, node.clone());
268                    if self.model.active_tab == DetailTab::References {
269                        self.spawn_browse_references(ctx, node);
270                    }
271                }
272            }
273            UiAction::OpenEndpointPicker => {
274                self.open_endpoint_picker(ctx);
275            }
276            UiAction::CloseEndpointPicker => {
277                self.model.endpoints_dialog_open = false;
278            }
279            UiAction::ForceRefreshEndpoints => {
280                if !self.model.endpoints_loading {
281                    self.spawn_discover_endpoints(ctx);
282                }
283            }
284            UiAction::SelectEndpoint(ep) => {
285                self.model.selected_endpoint = Some(ep);
286            }
287            UiAction::ClearSelectedEndpoint => {
288                self.model.selected_endpoint = None;
289            }
290            UiAction::SetAuthMode(mode) => self.model.auth_mode = mode,
291            UiAction::SetEndpointModeFilter(mode) => {
292                self.model.endpoint_mode_filter = mode;
293                self.select_first_matching_endpoint();
294            }
295            UiAction::AuthUsernameEdited(s) => self.model.auth_username = s,
296            UiAction::AuthPasswordEdited(s) => self.model.auth_password = s,
297            UiAction::AuthCertPathEdited(s) => self.model.auth_cert_path = s,
298            UiAction::AuthKeyPathEdited(s) => self.model.auth_key_path = s,
299            UiAction::PickAuthCertPath => {
300                if !self.model.file_picker_open {
301                    self.model.file_picker_open = true;
302                    let default_dir = self.model.auth_cert_path.clone();
303                    ctx.pick_file(
304                        &self.rt,
305                        &self.update_tx,
306                        FilePickTarget::CertPath,
307                        "Pick client certificate",
308                        &default_dir,
309                    );
310                }
311            }
312            UiAction::PickAuthKeyPath => {
313                if !self.model.file_picker_open {
314                    self.model.file_picker_open = true;
315                    let default_dir = self.model.auth_key_path.clone();
316                    ctx.pick_file(
317                        &self.rt,
318                        &self.update_tx,
319                        FilePickTarget::KeyPath,
320                        "Pick private key",
321                        &default_dir,
322                    );
323                }
324            }
325            UiAction::CopyPath(node) => self.spawn_browse_path(ctx, node),
326            UiAction::CopyNodeId(node) => {
327                let text = node.to_string();
328                ctx.set_clipboard(&text);
329                tracing::info!("copied node id: {text}");
330            }
331            UiAction::CopyNodeValue => {
332                let Some(summary) = self.model.node_summary.as_ref() else {
333                    tracing::warn!("no node summary loaded; nothing to copy");
334                    return;
335                };
336                match summary.attributes.iter().find(|a| a.name == "Value") {
337                    Some(attr) => {
338                        let text = render_value_for_clipboard(&attr.value);
339                        ctx.set_clipboard(&text);
340                        tracing::info!("copied value of {}", summary.node_id);
341                    }
342                    None => tracing::warn!(
343                        "selected node {} has no Value attribute",
344                        summary.node_id
345                    ),
346                }
347            }
348            UiAction::ConfirmConnect => {
349                if self.model.selected_endpoint.is_some() {
350                    self.model.endpoints_dialog_open = false;
351                    self.spawn_connect(ctx);
352                } else {
353                    tracing::warn!("ConfirmConnect with no endpoint selected");
354                }
355            }
356            UiAction::OpenMethodCall(node) => self.open_method_call(ctx, node),
357            UiAction::CloseMethodCall => {
358                self.model.method_call = None;
359            }
360            UiAction::MethodArgEdited { index, value } => match self.model.method_call.as_mut() {
361                Some(MethodCallState::Inputs { edited, call_error, field_errors, .. }) => {
362                    if let Some(slot) = edited.get_mut(index) {
363                        *slot = value;
364                        *call_error = None;
365                        if let Some(err_slot) = field_errors.get_mut(index) {
366                            *err_slot = None;
367                        }
368                    }
369                }
370                Some(MethodCallState::Result { edited, .. }) => {
371                    if let Some(slot) = edited.get_mut(index) {
372                        *slot = value;
373                    }
374                }
375                _ => {}
376            },
377            UiAction::CallMethodConfirmed => self.confirm_method_call(ctx),
378        }
379    }
380
381    fn open_method_call<C: FrontendCtx>(&mut self, ctx: &C, node: NodeId) {
382        self.model.method_call = Some(MethodCallState::Loading { node: node.clone() });
383        self.spawn_method_signature(ctx, node);
384    }
385
386    fn confirm_method_call<C: FrontendCtx>(&mut self, ctx: &C) {
387        let (node, signature, edited) = match self.model.method_call.as_ref() {
388            Some(MethodCallState::Inputs {
389                node, signature, edited, ..
390            })
391            | Some(MethodCallState::Result {
392                node, signature, edited, ..
393            }) => (node.clone(), signature.clone(), edited.clone()),
394            _ => return,
395        };
396
397        let mut variants = Vec::with_capacity(signature.inputs.len());
398        let mut field_errors = vec![None; signature.inputs.len()];
399        let mut any_error = false;
400        for (i, arg) in signature.inputs.iter().enumerate() {
401            let s = edited.get(i).cloned().unwrap_or_default();
402            match parse_variant(&s, &arg.data_type, arg.value_rank) {
403                Ok(v) => variants.push(v),
404                Err(e) => {
405                    field_errors[i] = Some(e);
406                    any_error = true;
407                }
408            }
409        }
410        if any_error {
411            self.model.method_call = Some(MethodCallState::Inputs {
412                node,
413                signature,
414                edited,
415                field_errors,
416                call_error: None,
417            });
418            return;
419        }
420
421        let parent = signature.parent_object.clone();
422        let method = signature.method_node.clone();
423        self.model.method_call = Some(MethodCallState::Calling {
424            node: node.clone(),
425            signature,
426            edited,
427        });
428        self.spawn_method_call(ctx, parent, method, variants, node);
429    }
430
431    fn spawn_method_signature<C: FrontendCtx>(&self, ctx: &C, node: NodeId) {
432        let client = self.client.clone();
433        let tx = self.update_tx.clone();
434        let ctx = ctx.clone();
435        self.rt.spawn(async move {
436            let result = client
437                .read_method_signature(&node)
438                .await
439                .map_err(|e| e.to_string());
440            let _ = tx.send(UiUpdate::MethodSignatureLoaded { node, result });
441            ctx.request_repaint();
442        });
443    }
444
445    fn spawn_method_call<C: FrontendCtx>(
446        &self,
447        ctx: &C,
448        parent: NodeId,
449        method: NodeId,
450        inputs: Vec<opcua::types::Variant>,
451        node: NodeId,
452    ) {
453        let client = self.client.clone();
454        let tx = self.update_tx.clone();
455        let ctx = ctx.clone();
456        self.rt.spawn(async move {
457            let result = client
458                .call_method(&parent, &method, inputs)
459                .await
460                .map_err(|e| e.to_string());
461            let _ = tx.send(UiUpdate::MethodCallFinished { node, result });
462            ctx.request_repaint();
463        });
464    }
465
466    fn toggle_expand<C: FrontendCtx>(&mut self, ctx: &C, node: NodeId) {
467        if self.model.tree.expanded.contains(&node) {
468            self.model.tree.expanded.remove(&node);
469        } else if self.model.tree.children.contains_key(&node) {
470            self.model.tree.expanded.insert(node);
471        } else if !self.model.tree.loading.contains(&node) {
472            self.model.tree.loading.insert(node.clone());
473            self.spawn_browse_children(ctx, node);
474        }
475    }
476
477    fn ensure_expanded<C: FrontendCtx>(&mut self, ctx: &C, node: NodeId) {
478        if self.model.tree.expanded.contains(&node) {
479            return;
480        }
481        if self.model.tree.children.contains_key(&node) {
482            self.model.tree.expanded.insert(node);
483        } else if !self.model.tree.loading.contains(&node) {
484            self.model.tree.loading.insert(node.clone());
485            self.spawn_browse_children(ctx, node);
486        }
487    }
488
489    fn select_node<C: FrontendCtx>(&mut self, ctx: &C, node: NodeId) {
490        self.model.selected = Some(node.clone());
491        self.model.node_summary = None;
492        self.model.references = None;
493        self.spawn_node_summary(ctx, node.clone());
494        if self.model.active_tab == DetailTab::References {
495            self.spawn_browse_references(ctx, node.clone());
496        }
497        self.spawn_resolve_path(ctx, node);
498    }
499
500    fn select_first_matching_endpoint(&mut self) {
501        if let Some(eps) = self.model.discovered_endpoints.as_ref() {
502            let mut filtered: Vec<&EndpointInfo> = eps
503                .iter()
504                .filter(|e| e.security_mode == self.model.endpoint_mode_filter)
505                .collect();
506            filtered.sort_by(|a, b| b.security_level.cmp(&a.security_level));
507            self.model.selected_endpoint = filtered.first().map(|&e| e.clone());
508        }
509    }
510
511    fn open_endpoint_picker<C: FrontendCtx>(&mut self, ctx: &C) {
512        self.model.endpoints_dialog_open = true;
513        if self.model.discovered_endpoints.is_none() && !self.model.endpoints_loading {
514            self.spawn_discover_endpoints(ctx);
515        }
516    }
517
518    fn spawn_resolve_path<C: FrontendCtx>(&self, ctx: &C, node: NodeId) {
519        let client = self.client.clone();
520        let tx = self.update_tx.clone();
521        let url = self.model.endpoint_url.clone();
522        let ctx = ctx.clone();
523        self.rt.spawn(async move {
524            match client.node_path(&node).await {
525                Ok(path) => {
526                    let _ = tx.send(UiUpdate::SelectionPathResolved { url, path });
527                    ctx.request_repaint();
528                }
529                Err(e) => tracing::debug!("node_path for {node} failed: {e}"),
530            }
531        });
532    }
533
534    pub fn navigate_to_textual_path<C: FrontendCtx>(&self, ctx: &C, path: String) {
535        let client = self.client.clone();
536        let tx = self.update_tx.clone();
537        let ctx = ctx.clone();
538        self.rt.spawn(async move {
539            let target = match client.resolve_browse_path(&path).await {
540                Ok(t) => t,
541                Err(e) => {
542                    tracing::warn!("resolve path '{path}' failed: {e}");
543                    return;
544                }
545            };
546            let chain = match client.node_path(&target).await {
547                Ok(c) => c,
548                Err(e) => {
549                    tracing::warn!("node_path for '{path}' failed: {e}");
550                    return;
551                }
552            };
553            if chain.is_empty() {
554                return;
555            }
556            let final_target = chain.last().cloned().unwrap();
557            for parent in chain.iter().take(chain.len() - 1) {
558                match client.browse_children(parent).await {
559                    Ok(children) => {
560                        let _ = tx.send(UiUpdate::ChildrenLoaded {
561                            parent: parent.clone(),
562                            children: Ok(children),
563                        });
564                    }
565                    Err(e) => {
566                        tracing::warn!("navigate: browse_children({parent}) failed: {e}");
567                        ctx.request_repaint();
568                        return;
569                    }
570                }
571            }
572            let _ = tx.send(UiUpdate::RestoreSelection(final_target));
573            ctx.request_repaint();
574        });
575    }
576
577    fn spawn_restore_selection<C: FrontendCtx>(&self, ctx: &C, path: Vec<NodeId>) {
578        let client = self.client.clone();
579        let tx = self.update_tx.clone();
580        let ctx = ctx.clone();
581        self.rt.spawn(async move {
582            if path.is_empty() {
583                return;
584            }
585            let target = path.last().cloned().unwrap();
586            for parent in path.iter().take(path.len() - 1) {
587                match client.browse_children(parent).await {
588                    Ok(children) => {
589                        let _ = tx.send(UiUpdate::ChildrenLoaded {
590                            parent: parent.clone(),
591                            children: Ok(children),
592                        });
593                    }
594                    Err(e) => {
595                        tracing::warn!("restore: browse_children({parent}) failed: {e}");
596                        ctx.request_repaint();
597                        return;
598                    }
599                }
600            }
601            let _ = tx.send(UiUpdate::RestoreSelection(target));
602            ctx.request_repaint();
603        });
604    }
605
606    fn spawn_connect<C: FrontendCtx>(&mut self, ctx: &C) {
607        let client = self.client.clone();
608        let tx = self.update_tx.clone();
609        let url = self.model.endpoint_url.clone();
610        let endpoint = self.model.selected_endpoint.clone();
611        let auth = AuthSpec {
612            mode: self.model.auth_mode,
613            username: self.model.auth_username.clone(),
614            password: self.model.auth_password.clone(),
615            cert_path: self.model.auth_cert_path.clone(),
616            key_path: self.model.auth_key_path.clone(),
617        };
618        let ctx = ctx.clone();
619        let _ = tx.send(UiUpdate::ConnectStarted);
620        self.rt.spawn(async move {
621            let r = client
622                .connect(&url, endpoint.as_ref(), &auth)
623                .await
624                .map_err(|e| e.to_string());
625            let _ = tx.send(UiUpdate::ConnectFinished(r));
626            ctx.request_repaint();
627        });
628    }
629
630    fn spawn_discover_endpoints<C: FrontendCtx>(&mut self, ctx: &C) {
631        self.model.endpoints_loading = true;
632        self.model.discovered_endpoints = None;
633        let client = self.client.clone();
634        let tx = self.update_tx.clone();
635        let url = self.model.endpoint_url.clone();
636        let ctx = ctx.clone();
637        self.rt.spawn(async move {
638            let r = client
639                .discover_endpoints(&url)
640                .await
641                .map_err(|e| e.to_string());
642            let _ = tx.send(UiUpdate::EndpointsDiscovered { url, result: r });
643            ctx.request_repaint();
644        });
645    }
646
647    fn spawn_disconnect<C: FrontendCtx>(&self, ctx: &C) {
648        let client = self.client.clone();
649        let tx = self.update_tx.clone();
650        let ctx = ctx.clone();
651        let _ = tx.send(UiUpdate::DisconnectStarted);
652        self.rt.spawn(async move {
653            if let Err(e) = client.disconnect().await {
654                tracing::warn!("disconnect: {e}");
655            }
656            let _ = tx.send(UiUpdate::DisconnectFinished);
657            ctx.request_repaint();
658        });
659    }
660
661    fn spawn_browse_children<C: FrontendCtx>(&self, ctx: &C, node: NodeId) {
662        let client = self.client.clone();
663        let tx = self.update_tx.clone();
664        let ctx = ctx.clone();
665        self.rt.spawn(async move {
666            let r = client.browse_children(&node).await.map_err(|e| e.to_string());
667            let _ = tx.send(UiUpdate::ChildrenLoaded {
668                parent: node,
669                children: r,
670            });
671            ctx.request_repaint();
672        });
673    }
674
675    fn spawn_node_summary<C: FrontendCtx>(&self, ctx: &C, node: NodeId) {
676        let client = self.client.clone();
677        let tx = self.update_tx.clone();
678        let ctx = ctx.clone();
679        self.rt.spawn(async move {
680            let r = client
681                .read_node_summary(&node)
682                .await
683                .map_err(|e| e.to_string());
684            let _ = tx.send(UiUpdate::SummaryLoaded { node, summary: r });
685            ctx.request_repaint();
686        });
687    }
688
689    fn spawn_browse_path<C: FrontendCtx>(&self, ctx: &C, node: NodeId) {
690        let client = self.client.clone();
691        let tx = self.update_tx.clone();
692        let ctx = ctx.clone();
693        self.rt.spawn(async move {
694            let r = client.browse_path(&node).await.map_err(|e| e.to_string());
695            let _ = tx.send(UiUpdate::PathReady { node, path: r });
696            ctx.request_repaint();
697        });
698    }
699
700    fn spawn_browse_references<C: FrontendCtx>(&mut self, ctx: &C, node: NodeId) {
701        self.model.references_loading = true;
702        let client = self.client.clone();
703        let tx = self.update_tx.clone();
704        let ctx = ctx.clone();
705        self.rt.spawn(async move {
706            let r = client
707                .browse_references(&node)
708                .await
709                .map_err(|e| e.to_string());
710            let _ = tx.send(UiUpdate::ReferencesLoaded { node, refs: r });
711            ctx.request_repaint();
712        });
713    }
714}
715
716fn render_value_for_clipboard(value: &ValueTree) -> String {
717    use std::fmt::Write as _;
718    fn render(v: &ValueTree, indent: usize, out: &mut String) {
719        let pad = "  ".repeat(indent);
720        match v {
721            ValueTree::Null => {
722                let _ = write!(out, "{pad}<null>");
723            }
724            ValueTree::Leaf(s) => {
725                let _ = write!(out, "{pad}{s}");
726            }
727            ValueTree::Array(items) => {
728                for (i, item) in items.iter().enumerate() {
729                    if i > 0 {
730                        out.push('\n');
731                    }
732                    let _ = write!(out, "{pad}[{i}]");
733                    out.push('\n');
734                    render(item, indent + 1, out);
735                }
736            }
737            ValueTree::Object(fields) => {
738                for (i, (k, val)) in fields.iter().enumerate() {
739                    if i > 0 {
740                        out.push('\n');
741                    }
742                    let _ = write!(out, "{pad}{k}:");
743                    out.push('\n');
744                    render(val, indent + 1, out);
745                }
746            }
747        }
748    }
749    let mut out = String::new();
750    render(value, 0, &mut out);
751    out
752}
753
754fn forward_logs(
755    mut log_rx: mpsc::UnboundedReceiver<UiUpdate>,
756    update_tx: mpsc::UnboundedSender<UiUpdate>,
757) {
758    std::thread::spawn(move || {
759        while let Some(msg) = log_rx.blocking_recv() {
760            if update_tx.send(msg).is_err() {
761                break;
762            }
763        }
764    });
765}