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_attribute_value, parse_variant};
9use crate::messages::{UiAction, UiUpdate};
10use crate::model::{AppModel, AttributeEditState, ConnectionState, DetailTab, MethodCallState};
11use crate::types::{AuthSpec, EndpointInfo, SubscriptionRow, 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::ConnectionLost => {
86                self.model.connection = ConnectionState::Reconnecting;
87                tracing::warn!("connection lost — reconnecting…");
88            }
89            UiUpdate::Reconnected { fresh } => {
90                if self.model.connection == ConnectionState::Reconnecting {
91                    self.model.connection = ConnectionState::Connected;
92                    tracing::info!("reconnected to {}", self.model.endpoint_url);
93                    if let Some(node) = self.model.selected.clone() {
94                        self.spawn_node_summary(ctx, node);
95                    }
96                    if fresh {
97                        self.resubscribe_all(ctx);
98                    }
99                }
100            }
101            UiUpdate::ReconnectFailed(e) => tracing::debug!("reconnect attempt failed: {e}"),
102            UiUpdate::DisconnectStarted => self.model.connection = ConnectionState::Disconnecting,
103            UiUpdate::DisconnectFinished => {
104                self.model.connection = ConnectionState::Disconnected;
105                self.model.reset_session_state();
106                tracing::info!("disconnected");
107            }
108            UiUpdate::ChildrenLoaded { parent, children } => {
109                self.model.tree.loading.remove(&parent);
110                match children {
111                    Ok(c) => {
112                        self.model.tree.children.insert(parent.clone(), c);
113                        self.model.tree.expanded.insert(parent);
114                    }
115                    Err(e) => tracing::error!("browse {parent} failed: {e}"),
116                }
117            }
118            UiUpdate::SummaryLoaded { node, summary } => {
119                if self.model.selected.as_ref() == Some(&node) {
120                    match summary {
121                        Ok(s) => self.model.node_summary = Some(s),
122                        Err(e) => tracing::error!("read summary {node} failed: {e}"),
123                    }
124                }
125            }
126            UiUpdate::ReferencesLoaded { node, refs } => {
127                if self.model.selected.as_ref() == Some(&node) {
128                    self.model.references_loading = false;
129                    match refs {
130                        Ok(rs) => self.model.references = Some(rs),
131                        Err(e) => tracing::error!("browse refs {node} failed: {e}"),
132                    }
133                }
134            }
135            UiUpdate::SelectionPathResolved { url, path } => {
136                self.model.last_selection_paths.insert(url, path);
137            }
138            UiUpdate::RestoreSelection(node) => {
139                self.model.selected = Some(node.clone());
140                self.spawn_node_summary(ctx, node.clone());
141                if self.model.active_tab == DetailTab::References {
142                    self.spawn_browse_references(ctx, node);
143                }
144            }
145            UiUpdate::PathReady { node, path } => match path {
146                Ok(p) => {
147                    ctx.set_clipboard(&p);
148                    tracing::info!("copied path: {p}");
149                }
150                Err(e) => tracing::error!("path for {node} failed: {e}"),
151            },
152            UiUpdate::CertPathPicked(p) => self.model.auth_cert_path = p,
153            UiUpdate::KeyPathPicked(p) => self.model.auth_key_path = p,
154            UiUpdate::FilePickerClosed => self.model.file_picker_open = false,
155            UiUpdate::EndpointsDiscovered { url, result } => {
156                if url != self.model.endpoint_url {
157                    tracing::debug!("dropping endpoints result for stale url {url}");
158                } else {
159                    self.model.endpoints_loading = false;
160                    match result {
161                        Ok(eps) => {
162                            tracing::info!("discovered {} endpoint(s)", eps.len());
163                            self.model.discovered_endpoints = Some(eps);
164                            self.select_first_matching_endpoint();
165                        }
166                        Err(e) => {
167                            tracing::error!("endpoint discovery failed: {e}");
168                            self.model.discovered_endpoints = Some(Vec::new());
169                        }
170                    }
171                }
172            }
173            UiUpdate::MethodSignatureLoaded { node, result } => {
174                if !self.method_call_targets(&node) {
175                    return;
176                }
177                match result {
178                    Ok(signature) => {
179                        let n_inputs = signature.inputs.len();
180                        self.model.method_call = Some(MethodCallState::Inputs {
181                            node,
182                            signature,
183                            edited: vec![String::new(); n_inputs],
184                            field_errors: vec![None; n_inputs],
185                            call_error: None,
186                        });
187                    }
188                    Err(error) => {
189                        tracing::error!("read method signature {node} failed: {error}");
190                        self.model.method_call =
191                            Some(MethodCallState::Failed { node, error });
192                    }
193                }
194            }
195            UiUpdate::MethodCallFinished { node, result } => {
196                if !self.method_call_targets(&node) {
197                    return;
198                }
199                let Some(MethodCallState::Calling {
200                    node, signature, edited,
201                }) = self.model.method_call.take()
202                else {
203                    return;
204                };
205                match result {
206                    Ok(outcome) => {
207                        self.model.method_call = Some(MethodCallState::Result {
208                            node,
209                            signature,
210                            edited,
211                            outcome,
212                        });
213                    }
214                    Err(error) => {
215                        tracing::error!("call method {node} failed: {error}");
216                        let n_inputs = signature.inputs.len();
217                        self.model.method_call = Some(MethodCallState::Inputs {
218                            node,
219                            signature,
220                            edited,
221                            field_errors: vec![None; n_inputs],
222                            call_error: Some(error),
223                        });
224                    }
225                }
226            }
227            UiUpdate::SubscribeFinished { node, result } => match result {
228                Ok(display_name) => {
229                    self.model.subscribing.remove(&node);
230                    if !self.model.subscriptions.iter().any(|r| r.node_id == node) {
231                        self.model.subscriptions.push(SubscriptionRow {
232                            node_id: node,
233                            display_name,
234                            value: "<pending>".to_string(),
235                            status: String::new(),
236                            timestamp: None,
237                        });
238                    }
239                }
240                Err(e) => {
241                    self.model.subscribing.remove(&node);
242                    self.model.subscriptions.retain(|r| r.node_id != node);
243                    tracing::error!("subscribe {node} failed: {e}");
244                }
245            },
246            UiUpdate::UnsubscribeFinished { node, result } => {
247                self.model.subscribing.remove(&node);
248                self.model.subscriptions.retain(|r| r.node_id != node);
249                if let Err(e) = result {
250                    tracing::error!("unsubscribe {node} failed: {e}");
251                }
252            }
253            UiUpdate::DataChange {
254                node,
255                value,
256                status,
257                timestamp,
258            } => {
259                if let Some(row) = self
260                    .model
261                    .subscriptions
262                    .iter_mut()
263                    .find(|r| r.node_id == node)
264                {
265                    row.value = value;
266                    row.status = status;
267                    row.timestamp = timestamp;
268                }
269            }
270            UiUpdate::AttributeEditTargetLoaded {
271                node,
272                attr_name,
273                result,
274            } => {
275                if !self.attr_edit_targets(&node, &attr_name) {
276                    return;
277                }
278                match result {
279                    Ok(target) => {
280                        let edited = target.current_value.clone();
281                        self.model.attr_edit = Some(AttributeEditState::Inputs {
282                            node,
283                            attr_name,
284                            target,
285                            edited,
286                            field_error: None,
287                            write_error: None,
288                        });
289                    }
290                    Err(error) => {
291                        tracing::error!(
292                            "read write target {node} {attr_name} failed: {error}"
293                        );
294                        self.model.attr_edit = Some(AttributeEditState::Failed {
295                            node,
296                            attr_name,
297                            error,
298                        });
299                    }
300                }
301            }
302            UiUpdate::AttributeWriteFinished {
303                node,
304                attr_name,
305                result,
306            } => {
307                if !self.attr_edit_targets(&node, &attr_name) {
308                    return;
309                }
310                match result {
311                    Ok(()) => {
312                        self.model.attr_edit = None;
313                        self.spawn_node_summary(ctx, node);
314                    }
315                    Err(error) => {
316                        tracing::error!("write {node} {attr_name} failed: {error}");
317                        let Some(AttributeEditState::Writing {
318                            node,
319                            attr_name,
320                            target,
321                            edited,
322                        }) = self.model.attr_edit.take()
323                        else {
324                            return;
325                        };
326                        self.model.attr_edit = Some(AttributeEditState::Inputs {
327                            node,
328                            attr_name,
329                            target,
330                            edited,
331                            field_error: None,
332                            write_error: Some(error),
333                        });
334                    }
335                }
336            }
337            UiUpdate::Log(line) => self.model.push_log(line),
338        }
339    }
340
341    fn attr_edit_targets(&self, node: &NodeId, attr_name: &str) -> bool {
342        self.model
343            .attr_edit
344            .as_ref()
345            .map(|s| s.node() == node && s.attr_name() == attr_name)
346            .unwrap_or(false)
347    }
348
349    fn method_call_targets(&self, node: &NodeId) -> bool {
350        self.model
351            .method_call
352            .as_ref()
353            .map(|s| s.node() == node)
354            .unwrap_or(false)
355    }
356
357    pub fn dispatch<C: FrontendCtx>(&mut self, ctx: &C, action: UiAction) {
358        match action {
359            UiAction::EndpointEdited(s) => {
360                if s != self.model.endpoint_url {
361                    self.model.endpoint_url = s;
362                    self.model.discovered_endpoints = None;
363                    self.model.selected_endpoint = None;
364                    self.model.endpoints_loading = false;
365                    self.model.apply_saved_connection_prefs();
366                }
367            }
368            UiAction::TabSelected(t) => {
369                self.model.active_tab = t;
370                if t == DetailTab::References
371                    && let Some(node) = self.model.selected.clone()
372                    && self.model.references.is_none()
373                    && !self.model.references_loading
374                {
375                    self.spawn_browse_references(ctx, node);
376                }
377            }
378            UiAction::ConnectClicked => {
379                if self.model.selected_endpoint.is_none() {
380                    tracing::info!("no endpoint selected; opening picker");
381                    self.open_endpoint_picker(ctx);
382                } else {
383                    let ep = self.model.selected_endpoint.as_ref().unwrap();
384                    tracing::info!(
385                        "connecting with {} / {}",
386                        ep.security_policy,
387                        ep.security_mode.label()
388                    );
389                    self.spawn_connect(ctx);
390                }
391            }
392            UiAction::DisconnectClicked => self.spawn_disconnect(ctx),
393            UiAction::NodeToggleExpand(n) => self.toggle_expand(ctx, n),
394            UiAction::NodeSelected(n) => self.select_node(ctx, n),
395            UiAction::ClearSelection => {
396                self.model.selected = None;
397                self.model.node_summary = None;
398                self.model.references = None;
399                self.model.references_loading = false;
400            }
401            UiAction::RefreshClicked => {
402                if let Some(node) = self.model.selected.clone() {
403                    self.spawn_node_summary(ctx, node.clone());
404                    if self.model.active_tab == DetailTab::References {
405                        self.spawn_browse_references(ctx, node);
406                    }
407                }
408            }
409            UiAction::OpenEndpointPicker => {
410                self.open_endpoint_picker(ctx);
411            }
412            UiAction::CloseEndpointPicker => {
413                self.model.endpoints_dialog_open = false;
414            }
415            UiAction::ForceRefreshEndpoints => {
416                if !self.model.endpoints_loading {
417                    self.spawn_discover_endpoints(ctx);
418                }
419            }
420            UiAction::SelectEndpoint(ep) => {
421                self.model.selected_endpoint = Some(ep);
422            }
423            UiAction::ClearSelectedEndpoint => {
424                self.model.selected_endpoint = None;
425            }
426            UiAction::SetAuthMode(mode) => self.model.auth_mode = mode,
427            UiAction::SetEndpointModeFilter(mode) => {
428                self.model.endpoint_mode_filter = mode;
429                self.select_first_matching_endpoint();
430            }
431            UiAction::AuthUsernameEdited(s) => self.model.auth_username = s,
432            UiAction::AuthPasswordEdited(s) => self.model.auth_password = s,
433            UiAction::AuthCertPathEdited(s) => self.model.auth_cert_path = s,
434            UiAction::AuthKeyPathEdited(s) => self.model.auth_key_path = s,
435            UiAction::PickAuthCertPath => {
436                if !self.model.file_picker_open {
437                    self.model.file_picker_open = true;
438                    let default_dir = self.model.auth_cert_path.clone();
439                    ctx.pick_file(
440                        &self.rt,
441                        &self.update_tx,
442                        FilePickTarget::CertPath,
443                        "Pick client certificate",
444                        &default_dir,
445                    );
446                }
447            }
448            UiAction::PickAuthKeyPath => {
449                if !self.model.file_picker_open {
450                    self.model.file_picker_open = true;
451                    let default_dir = self.model.auth_key_path.clone();
452                    ctx.pick_file(
453                        &self.rt,
454                        &self.update_tx,
455                        FilePickTarget::KeyPath,
456                        "Pick private key",
457                        &default_dir,
458                    );
459                }
460            }
461            UiAction::CopyPath(node) => self.spawn_browse_path(ctx, node),
462            UiAction::CopyNodeId(node) => {
463                let text = node.to_string();
464                ctx.set_clipboard(&text);
465                tracing::info!("copied node id: {text}");
466            }
467            UiAction::CopyNodeValue => {
468                let Some(summary) = self.model.node_summary.as_ref() else {
469                    tracing::warn!("no node summary loaded; nothing to copy");
470                    return;
471                };
472                match summary.attributes.iter().find(|a| a.name == "Value") {
473                    Some(attr) => {
474                        let text = render_value_for_clipboard(&attr.value);
475                        ctx.set_clipboard(&text);
476                        tracing::info!("copied value of {}", summary.node_id);
477                    }
478                    None => tracing::warn!(
479                        "selected node {} has no Value attribute",
480                        summary.node_id
481                    ),
482                }
483            }
484            UiAction::ConfirmConnect => {
485                if self.model.selected_endpoint.is_some() {
486                    self.model.endpoints_dialog_open = false;
487                    self.spawn_connect(ctx);
488                } else {
489                    tracing::warn!("ConfirmConnect with no endpoint selected");
490                }
491            }
492            UiAction::OpenMethodCall(node) => self.open_method_call(ctx, node),
493            UiAction::CloseMethodCall => {
494                self.model.method_call = None;
495            }
496            UiAction::MethodArgEdited { index, value } => match self.model.method_call.as_mut() {
497                Some(MethodCallState::Inputs { edited, call_error, field_errors, .. }) => {
498                    if let Some(slot) = edited.get_mut(index) {
499                        *slot = value;
500                        *call_error = None;
501                        if let Some(err_slot) = field_errors.get_mut(index) {
502                            *err_slot = None;
503                        }
504                    }
505                }
506                Some(MethodCallState::Result { edited, .. }) => {
507                    if let Some(slot) = edited.get_mut(index) {
508                        *slot = value;
509                    }
510                }
511                _ => {}
512            },
513            UiAction::CallMethodConfirmed => self.confirm_method_call(ctx),
514            UiAction::Subscribe(node) => {
515                if self.model.subscribing.insert(node.clone()) {
516                    self.spawn_subscribe(ctx, node);
517                }
518            }
519            UiAction::Unsubscribe(node) => {
520                if self.model.subscribing.insert(node.clone()) {
521                    self.spawn_unsubscribe(ctx, node);
522                }
523            }
524            UiAction::OpenAttributeEdit { node, attr_name } => {
525                self.open_attribute_edit(ctx, node, attr_name);
526            }
527            UiAction::CloseAttributeEdit => {
528                self.model.attr_edit = None;
529            }
530            UiAction::AttributeValueEdited(s) => {
531                if let Some(AttributeEditState::Inputs {
532                    edited,
533                    field_error,
534                    write_error,
535                    ..
536                }) = self.model.attr_edit.as_mut()
537                {
538                    *edited = s;
539                    *field_error = None;
540                    *write_error = None;
541                }
542            }
543            UiAction::ConfirmAttributeEdit => self.confirm_attribute_edit(ctx),
544        }
545    }
546
547    fn open_attribute_edit<C: FrontendCtx>(
548        &mut self,
549        ctx: &C,
550        node: NodeId,
551        attr_name: String,
552    ) {
553        self.model.attr_edit = Some(AttributeEditState::Loading {
554            node: node.clone(),
555            attr_name: attr_name.clone(),
556        });
557        self.spawn_read_write_target(ctx, node, attr_name);
558    }
559
560    fn confirm_attribute_edit<C: FrontendCtx>(&mut self, ctx: &C) {
561        let Some(AttributeEditState::Inputs {
562            node,
563            attr_name,
564            target,
565            edited,
566            ..
567        }) = self.model.attr_edit.as_ref()
568        else {
569            return;
570        };
571        let value = match parse_attribute_value(&target.spec, edited) {
572            Ok(v) => v,
573            Err(e) => {
574                if let Some(AttributeEditState::Inputs { field_error, .. }) =
575                    self.model.attr_edit.as_mut()
576                {
577                    *field_error = Some(e);
578                }
579                return;
580            }
581        };
582        let node = node.clone();
583        let attr_name = attr_name.clone();
584        let target = target.clone();
585        let edited = edited.clone();
586        self.model.attr_edit = Some(AttributeEditState::Writing {
587            node: node.clone(),
588            attr_name: attr_name.clone(),
589            target,
590            edited,
591        });
592        self.spawn_write_attribute(ctx, node, attr_name, value);
593    }
594
595    fn spawn_read_write_target<C: FrontendCtx>(
596        &self,
597        ctx: &C,
598        node: NodeId,
599        attr_name: String,
600    ) {
601        let client = self.client.clone();
602        let tx = self.update_tx.clone();
603        let ctx = ctx.clone();
604        self.rt.spawn(async move {
605            let result = client
606                .read_write_target(&node, &attr_name)
607                .await
608                .map_err(|e| e.to_string());
609            let _ = tx.send(UiUpdate::AttributeEditTargetLoaded {
610                node,
611                attr_name,
612                result,
613            });
614            ctx.request_repaint();
615        });
616    }
617
618    fn spawn_write_attribute<C: FrontendCtx>(
619        &self,
620        ctx: &C,
621        node: NodeId,
622        attr_name: String,
623        value: opcua::types::Variant,
624    ) {
625        let client = self.client.clone();
626        let tx = self.update_tx.clone();
627        let ctx = ctx.clone();
628        self.rt.spawn(async move {
629            let result = client
630                .write_attribute(&node, &attr_name, value)
631                .await
632                .map_err(|e| e.to_string());
633            let _ = tx.send(UiUpdate::AttributeWriteFinished {
634                node,
635                attr_name,
636                result,
637            });
638            ctx.request_repaint();
639        });
640    }
641
642    fn open_method_call<C: FrontendCtx>(&mut self, ctx: &C, node: NodeId) {
643        self.model.method_call = Some(MethodCallState::Loading { node: node.clone() });
644        self.spawn_method_signature(ctx, node);
645    }
646
647    fn confirm_method_call<C: FrontendCtx>(&mut self, ctx: &C) {
648        let (node, signature, edited) = match self.model.method_call.as_ref() {
649            Some(MethodCallState::Inputs {
650                node, signature, edited, ..
651            })
652            | Some(MethodCallState::Result {
653                node, signature, edited, ..
654            }) => (node.clone(), signature.clone(), edited.clone()),
655            _ => return,
656        };
657
658        let mut variants = Vec::with_capacity(signature.inputs.len());
659        let mut field_errors = vec![None; signature.inputs.len()];
660        let mut any_error = false;
661        for (i, arg) in signature.inputs.iter().enumerate() {
662            let s = edited.get(i).cloned().unwrap_or_default();
663            match parse_variant(&s, &arg.data_type, arg.value_rank) {
664                Ok(v) => variants.push(v),
665                Err(e) => {
666                    field_errors[i] = Some(e);
667                    any_error = true;
668                }
669            }
670        }
671        if any_error {
672            self.model.method_call = Some(MethodCallState::Inputs {
673                node,
674                signature,
675                edited,
676                field_errors,
677                call_error: None,
678            });
679            return;
680        }
681
682        let parent = signature.parent_object.clone();
683        let method = signature.method_node.clone();
684        self.model.method_call = Some(MethodCallState::Calling {
685            node: node.clone(),
686            signature,
687            edited,
688        });
689        self.spawn_method_call(ctx, parent, method, variants, node);
690    }
691
692    fn spawn_method_signature<C: FrontendCtx>(&self, ctx: &C, node: NodeId) {
693        let client = self.client.clone();
694        let tx = self.update_tx.clone();
695        let ctx = ctx.clone();
696        self.rt.spawn(async move {
697            let result = client
698                .read_method_signature(&node)
699                .await
700                .map_err(|e| e.to_string());
701            let _ = tx.send(UiUpdate::MethodSignatureLoaded { node, result });
702            ctx.request_repaint();
703        });
704    }
705
706    fn spawn_method_call<C: FrontendCtx>(
707        &self,
708        ctx: &C,
709        parent: NodeId,
710        method: NodeId,
711        inputs: Vec<opcua::types::Variant>,
712        node: NodeId,
713    ) {
714        let client = self.client.clone();
715        let tx = self.update_tx.clone();
716        let ctx = ctx.clone();
717        self.rt.spawn(async move {
718            let result = client
719                .call_method(&parent, &method, inputs)
720                .await
721                .map_err(|e| e.to_string());
722            let _ = tx.send(UiUpdate::MethodCallFinished { node, result });
723            ctx.request_repaint();
724        });
725    }
726
727    fn toggle_expand<C: FrontendCtx>(&mut self, ctx: &C, node: NodeId) {
728        if self.model.tree.expanded.contains(&node) {
729            self.model.tree.expanded.remove(&node);
730        } else if self.model.tree.children.contains_key(&node) {
731            self.model.tree.expanded.insert(node);
732        } else if !self.model.tree.loading.contains(&node) {
733            self.model.tree.loading.insert(node.clone());
734            self.spawn_browse_children(ctx, node);
735        }
736    }
737
738    fn ensure_expanded<C: FrontendCtx>(&mut self, ctx: &C, node: NodeId) {
739        if self.model.tree.expanded.contains(&node) {
740            return;
741        }
742        if self.model.tree.children.contains_key(&node) {
743            self.model.tree.expanded.insert(node);
744        } else if !self.model.tree.loading.contains(&node) {
745            self.model.tree.loading.insert(node.clone());
746            self.spawn_browse_children(ctx, node);
747        }
748    }
749
750    fn select_node<C: FrontendCtx>(&mut self, ctx: &C, node: NodeId) {
751        self.model.selected = Some(node.clone());
752        self.model.node_summary = None;
753        self.model.references = None;
754        self.spawn_node_summary(ctx, node.clone());
755        if self.model.active_tab == DetailTab::References {
756            self.spawn_browse_references(ctx, node.clone());
757        }
758        self.spawn_resolve_path(ctx, node);
759    }
760
761    fn select_first_matching_endpoint(&mut self) {
762        if let Some(eps) = self.model.discovered_endpoints.as_ref() {
763            let mut filtered: Vec<&EndpointInfo> = eps
764                .iter()
765                .filter(|e| e.security_mode == self.model.endpoint_mode_filter)
766                .collect();
767            filtered.sort_by(|a, b| b.security_level.cmp(&a.security_level));
768            self.model.selected_endpoint = filtered.first().map(|&e| e.clone());
769        }
770    }
771
772    fn open_endpoint_picker<C: FrontendCtx>(&mut self, ctx: &C) {
773        self.model.endpoints_dialog_open = true;
774        if self.model.discovered_endpoints.is_none() && !self.model.endpoints_loading {
775            self.spawn_discover_endpoints(ctx);
776        }
777    }
778
779    fn spawn_resolve_path<C: FrontendCtx>(&self, ctx: &C, node: NodeId) {
780        let client = self.client.clone();
781        let tx = self.update_tx.clone();
782        let url = self.model.endpoint_url.clone();
783        let ctx = ctx.clone();
784        self.rt.spawn(async move {
785            match client.node_path(&node).await {
786                Ok(path) => {
787                    let _ = tx.send(UiUpdate::SelectionPathResolved { url, path });
788                    ctx.request_repaint();
789                }
790                Err(e) => tracing::debug!("node_path for {node} failed: {e}"),
791            }
792        });
793    }
794
795    pub fn navigate_to_textual_path<C: FrontendCtx>(&self, ctx: &C, path: String) {
796        let client = self.client.clone();
797        let tx = self.update_tx.clone();
798        let ctx = ctx.clone();
799        self.rt.spawn(async move {
800            let target = match client.resolve_browse_path(&path).await {
801                Ok(t) => t,
802                Err(e) => {
803                    tracing::warn!("resolve path '{path}' failed: {e}");
804                    return;
805                }
806            };
807            let chain = match client.node_path(&target).await {
808                Ok(c) => c,
809                Err(e) => {
810                    tracing::warn!("node_path for '{path}' failed: {e}");
811                    return;
812                }
813            };
814            if chain.is_empty() {
815                return;
816            }
817            let final_target = chain.last().cloned().unwrap();
818            for parent in chain.iter().take(chain.len() - 1) {
819                match client.browse_children(parent).await {
820                    Ok(children) => {
821                        let _ = tx.send(UiUpdate::ChildrenLoaded {
822                            parent: parent.clone(),
823                            children: Ok(children),
824                        });
825                    }
826                    Err(e) => {
827                        tracing::warn!("navigate: browse_children({parent}) failed: {e}");
828                        ctx.request_repaint();
829                        return;
830                    }
831                }
832            }
833            let _ = tx.send(UiUpdate::RestoreSelection(final_target));
834            ctx.request_repaint();
835        });
836    }
837
838    fn spawn_restore_selection<C: FrontendCtx>(&self, ctx: &C, path: Vec<NodeId>) {
839        let client = self.client.clone();
840        let tx = self.update_tx.clone();
841        let ctx = ctx.clone();
842        self.rt.spawn(async move {
843            if path.is_empty() {
844                return;
845            }
846            let target = path.last().cloned().unwrap();
847            for parent in path.iter().take(path.len() - 1) {
848                match client.browse_children(parent).await {
849                    Ok(children) => {
850                        let _ = tx.send(UiUpdate::ChildrenLoaded {
851                            parent: parent.clone(),
852                            children: Ok(children),
853                        });
854                    }
855                    Err(e) => {
856                        tracing::warn!("restore: browse_children({parent}) failed: {e}");
857                        ctx.request_repaint();
858                        return;
859                    }
860                }
861            }
862            let _ = tx.send(UiUpdate::RestoreSelection(target));
863            ctx.request_repaint();
864        });
865    }
866
867    fn spawn_connect<C: FrontendCtx>(&mut self, ctx: &C) {
868        let client = self.client.clone();
869        let tx = self.update_tx.clone();
870        let url = self.model.endpoint_url.clone();
871        let endpoint = self.model.selected_endpoint.clone();
872        let auth = AuthSpec {
873            mode: self.model.auth_mode,
874            username: self.model.auth_username.clone(),
875            password: self.model.auth_password.clone(),
876            cert_path: self.model.auth_cert_path.clone(),
877            key_path: self.model.auth_key_path.clone(),
878        };
879        let ctx = ctx.clone();
880        let _ = tx.send(UiUpdate::ConnectStarted);
881        self.rt.spawn(async move {
882            let r = client
883                .connect(&url, endpoint.as_ref(), &auth, tx.clone())
884                .await
885                .map_err(|e| e.to_string());
886            let _ = tx.send(UiUpdate::ConnectFinished(r));
887            ctx.request_repaint();
888        });
889    }
890
891    fn spawn_discover_endpoints<C: FrontendCtx>(&mut self, ctx: &C) {
892        self.model.endpoints_loading = true;
893        self.model.discovered_endpoints = None;
894        let client = self.client.clone();
895        let tx = self.update_tx.clone();
896        let url = self.model.endpoint_url.clone();
897        let ctx = ctx.clone();
898        self.rt.spawn(async move {
899            let r = client
900                .discover_endpoints(&url)
901                .await
902                .map_err(|e| e.to_string());
903            let _ = tx.send(UiUpdate::EndpointsDiscovered { url, result: r });
904            ctx.request_repaint();
905        });
906    }
907
908    fn spawn_disconnect<C: FrontendCtx>(&self, ctx: &C) {
909        let client = self.client.clone();
910        let tx = self.update_tx.clone();
911        let ctx = ctx.clone();
912        let _ = tx.send(UiUpdate::DisconnectStarted);
913        self.rt.spawn(async move {
914            if let Err(e) = client.disconnect().await {
915                tracing::warn!("disconnect: {e}");
916            }
917            let _ = tx.send(UiUpdate::DisconnectFinished);
918            ctx.request_repaint();
919        });
920    }
921
922    fn spawn_browse_children<C: FrontendCtx>(&self, ctx: &C, node: NodeId) {
923        let client = self.client.clone();
924        let tx = self.update_tx.clone();
925        let ctx = ctx.clone();
926        self.rt.spawn(async move {
927            let r = client.browse_children(&node).await.map_err(|e| e.to_string());
928            let _ = tx.send(UiUpdate::ChildrenLoaded {
929                parent: node,
930                children: r,
931            });
932            ctx.request_repaint();
933        });
934    }
935
936    fn spawn_node_summary<C: FrontendCtx>(&self, ctx: &C, node: NodeId) {
937        let client = self.client.clone();
938        let tx = self.update_tx.clone();
939        let ctx = ctx.clone();
940        self.rt.spawn(async move {
941            let r = client
942                .read_node_summary(&node)
943                .await
944                .map_err(|e| e.to_string());
945            let _ = tx.send(UiUpdate::SummaryLoaded { node, summary: r });
946            ctx.request_repaint();
947        });
948    }
949
950    fn spawn_browse_path<C: FrontendCtx>(&self, ctx: &C, node: NodeId) {
951        let client = self.client.clone();
952        let tx = self.update_tx.clone();
953        let ctx = ctx.clone();
954        self.rt.spawn(async move {
955            let r = client.browse_path(&node).await.map_err(|e| e.to_string());
956            let _ = tx.send(UiUpdate::PathReady { node, path: r });
957            ctx.request_repaint();
958        });
959    }
960
961    fn spawn_subscribe<C: FrontendCtx>(&self, ctx: &C, node: NodeId) {
962        let client = self.client.clone();
963        let tx = self.update_tx.clone();
964        let ctx = ctx.clone();
965        let data_tx = self.update_tx.clone();
966        self.rt.spawn(async move {
967            let result = client
968                .subscribe(node.clone(), data_tx)
969                .await
970                .map_err(|e| e.to_string());
971            let _ = tx.send(UiUpdate::SubscribeFinished { node, result });
972            ctx.request_repaint();
973        });
974    }
975
976    fn resubscribe_all<C: FrontendCtx>(&mut self, ctx: &C) {
977        let nodes: Vec<NodeId> = self
978            .model
979            .subscriptions
980            .iter()
981            .map(|r| r.node_id.clone())
982            .collect();
983        if nodes.is_empty() {
984            return;
985        }
986        for node in &nodes {
987            self.model.subscribing.insert(node.clone());
988        }
989        let client = self.client.clone();
990        let tx = self.update_tx.clone();
991        let data_tx = self.update_tx.clone();
992        let ctx = ctx.clone();
993        self.rt.spawn(async move {
994            client.reset_subscription_state().await;
995            for node in nodes {
996                let result = client
997                    .subscribe(node.clone(), data_tx.clone())
998                    .await
999                    .map_err(|e| e.to_string());
1000                let _ = tx.send(UiUpdate::SubscribeFinished { node, result });
1001            }
1002            ctx.request_repaint();
1003        });
1004    }
1005
1006    fn spawn_unsubscribe<C: FrontendCtx>(&self, ctx: &C, node: NodeId) {
1007        let client = self.client.clone();
1008        let tx = self.update_tx.clone();
1009        let ctx = ctx.clone();
1010        self.rt.spawn(async move {
1011            let result = client.unsubscribe(&node).await.map_err(|e| e.to_string());
1012            let _ = tx.send(UiUpdate::UnsubscribeFinished { node, result });
1013            ctx.request_repaint();
1014        });
1015    }
1016
1017    fn spawn_browse_references<C: FrontendCtx>(&mut self, ctx: &C, node: NodeId) {
1018        self.model.references_loading = true;
1019        let client = self.client.clone();
1020        let tx = self.update_tx.clone();
1021        let ctx = ctx.clone();
1022        self.rt.spawn(async move {
1023            let r = client
1024                .browse_references(&node)
1025                .await
1026                .map_err(|e| e.to_string());
1027            let _ = tx.send(UiUpdate::ReferencesLoaded { node, refs: r });
1028            ctx.request_repaint();
1029        });
1030    }
1031}
1032
1033fn render_value_for_clipboard(value: &ValueTree) -> String {
1034    use std::fmt::Write as _;
1035    fn render(v: &ValueTree, indent: usize, out: &mut String) {
1036        let pad = "  ".repeat(indent);
1037        match v {
1038            ValueTree::Null => {
1039                let _ = write!(out, "{pad}<null>");
1040            }
1041            ValueTree::Leaf(s) => {
1042                let _ = write!(out, "{pad}{s}");
1043            }
1044            ValueTree::Array(items) => {
1045                for (i, item) in items.iter().enumerate() {
1046                    if i > 0 {
1047                        out.push('\n');
1048                    }
1049                    let _ = write!(out, "{pad}[{i}]");
1050                    out.push('\n');
1051                    render(item, indent + 1, out);
1052                }
1053            }
1054            ValueTree::Object(fields) => {
1055                for (i, (k, val)) in fields.iter().enumerate() {
1056                    if i > 0 {
1057                        out.push('\n');
1058                    }
1059                    let _ = write!(out, "{pad}{k}:");
1060                    out.push('\n');
1061                    render(val, indent + 1, out);
1062                }
1063            }
1064        }
1065    }
1066    let mut out = String::new();
1067    render(value, 0, &mut out);
1068    out
1069}
1070
1071fn forward_logs(
1072    mut log_rx: mpsc::UnboundedReceiver<UiUpdate>,
1073    update_tx: mpsc::UnboundedSender<UiUpdate>,
1074) {
1075    std::thread::spawn(move || {
1076        while let Some(msg) = log_rx.blocking_recv() {
1077            if update_tx.send(msg).is_err() {
1078                break;
1079            }
1080        }
1081    });
1082}