Skip to content

Commit f25b514

Browse files
habibtalibclaude
andcommitted
feat: async ConnectionService seam, typed errors, and AsyncState foundation
Converts the single backend seam (ConnectionService) from sync to async, defines the studio's typed error model, stands up an instantiable NodeDbConnectionService stub, and establishes the reusable loading/empty/error UI pattern — while MockConnectionService keeps working identically. No real data is wired yet; this lays the foundation for client-backed wiring. - ConnectionService is now #[async_trait(?Send)] returning Result<_, StudioError>; MockConnectionService satisfies it and renders mock data identically. All connect() call sites + app.rs seeding migrated to use_resource/spawn with no signal guard held across .await. - NodeDbConnectionService wraps Option<NativeClient> (enables nodedb-client's `native` feature), returns StudioError::NotConnected until connected, and is instantiable in app.rs. - StudioError: 8-variant thiserror enum mapped from nodedb-client's NodeDbError with #[source] preservation and is_retriable() delegation. No unwrap/expect/panic!/Result<T, String> in seam code. - AsyncState<T> (Loading/Empty/Loaded/Error) with a unit-tested from_value mapping + shared AsyncView component (Retry gated on is_retriable() -> Resource::restart()), proven on the notifications feed. Adds async-trait 0.1. cargo fmt/clippy -D warnings/nextest all green (27 tests). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 6e33520 commit f25b514

16 files changed

Lines changed: 777 additions & 39 deletions

Cargo.lock

Lines changed: 91 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,10 @@ homepage = "https://nodedb.dev"
1414

1515
[workspace.dependencies]
1616
# Internal NodeDB crates.
17-
nodedb-client = "0.3.0"
17+
nodedb-client = { version = "0.3.0", features = ["native"] }
1818
nodedb-types = "0.3.0"
1919
# Async
20+
async-trait = "0.1"
2021
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
2122
# UI. The `router` feature re-exports the router through dioxus::prelude.
2223
# Desktop-only for the skeleton.

nodedb-studio/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,4 +20,5 @@ tokio = { workspace = true }
2020
serde = { workspace = true }
2121
sonic-rs = { workspace = true }
2222
thiserror = { workspace = true }
23+
async-trait = { workspace = true }
2324
tracing = { workspace = true }

nodedb-studio/assets/styles.css

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1459,6 +1459,35 @@
14591459
}
14601460
.notif-footer a:hover { color: var(--text-primary); }
14611461

1462+
/* ============================================================
1463+
Async render states (shared AsyncView: loading / empty / error)
1464+
============================================================ */
1465+
.async-loading {
1466+
padding: 40px 20px;
1467+
text-align: center;
1468+
color: var(--text-tertiary);
1469+
font-size: 12px;
1470+
}
1471+
.async-empty {
1472+
padding: 40px 20px;
1473+
text-align: center;
1474+
color: var(--text-tertiary);
1475+
font-size: 12px;
1476+
}
1477+
.async-error {
1478+
padding: 32px 20px;
1479+
text-align: center;
1480+
font-size: 12px;
1481+
display: flex;
1482+
flex-direction: column;
1483+
align-items: center;
1484+
gap: 10px;
1485+
}
1486+
.async-error-msg {
1487+
color: var(--text-secondary);
1488+
max-width: 280px;
1489+
}
1490+
14621491
/* ============================================================
14631492
Avatar popover (identity menu)
14641493
============================================================ */

nodedb-studio/src/app.rs

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,11 @@ use std::rc::Rc;
1313
use dioxus::prelude::*;
1414

1515
use crate::modals::ModalHost;
16+
use crate::models::notification::Notification;
1617
use crate::services::connection_service::{ConnectionService, MockConnectionService};
18+
use crate::services::nodedb_service::NodeDbConnectionService;
1719
use crate::state::connection::ActiveConnection;
20+
use crate::state::connections_registry::SavedConnection;
1821
use crate::state::preferences::Preferences;
1922
use crate::state::ui::ModalKind;
2023
use crate::views::connection_manager::ConnectionManager;
@@ -29,19 +32,39 @@ pub fn App() -> Element {
2932
// real client can replace the mock without touching consumers.
3033
let service: Rc<dyn ConnectionService> = Rc::new(MockConnectionService);
3134

32-
// Seed the registry and notification feed once, from the service.
33-
let registry = service.list_connections();
34-
let notifications = service.notifications();
35+
// Prove the real-client stub is instantiable and object-safe behind the
36+
// seam (SEAM-02). Not the active impl this phase — Phase 2 swaps it in —
37+
// but constructing it here guarantees it compiles against the trait.
38+
let _real: Rc<dyn ConnectionService> = Rc::new(NodeDbConnectionService::default());
3539

3640
use_context_provider(|| service.clone());
3741
use_context_provider(|| Signal::new(None::<ActiveConnection>));
38-
use_context_provider(|| Signal::new(registry));
39-
use_context_provider(|| Signal::new(notifications));
42+
let mut registry = use_context_provider(|| Signal::new(Vec::<SavedConnection>::new()));
43+
let mut notifications = use_context_provider(|| Signal::new(Vec::<Notification>::new()));
4044
use_context_provider(|| Signal::new(Preferences::default()));
4145
// Modal state is provided here (not in Studio) because Preferences is
4246
// reachable while disconnected and via Cmd+, in either state.
4347
use_context_provider(|| Signal::new(None::<ModalKind>));
4448

49+
// Seed the registry + notification feed asynchronously, at the seam. The
50+
// mock resolves instantly; the real client awaits the network. The guard
51+
// rule: clone the Rc before the async block and `.set()` the signals only
52+
// AFTER the await resolves — never hold a read/write guard across `.await`.
53+
{
54+
let svc = service.clone();
55+
use_resource(move || {
56+
let svc = svc.clone();
57+
async move {
58+
if let Ok(conns) = svc.list_connections().await {
59+
registry.set(conns);
60+
}
61+
if let Ok(notifs) = svc.notifications().await {
62+
notifications.set(notifs);
63+
}
64+
}
65+
});
66+
}
67+
4568
let active = use_context::<Signal<Option<ActiveConnection>>>();
4669

4770
rsx! {
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
//! Shared loading/empty/error renderer for any seam-backed read.
2+
//!
3+
//! The caller maps its `use_resource` result to `AsyncState<T>` (plain Rust),
4+
//! renders the `Loaded(T)` case itself, and delegates the three non-loaded
5+
//! states to this component. Keeps every wired view from re-implementing the
6+
//! spinner / empty / error+retry markup.
7+
//!
8+
//! Calling convention (wired up by plan 01-04):
9+
//! ```ignore
10+
//! match AsyncState::from_value(resource.read().clone()) {
11+
//! AsyncState::Loaded(data) => rsx! { /* caller's own markup for `data` */ },
12+
//! other => rsx! {
13+
//! AsyncView {
14+
//! loading: matches!(other, AsyncState::Loading),
15+
//! empty: matches!(other, AsyncState::Empty),
16+
//! error: match &other { AsyncState::Error(e) => Some(e.to_string()), _ => None },
17+
//! retriable: matches!(&other, AsyncState::Error(e) if e.is_retriable()),
18+
//! on_retry: move |_| resource.restart(),
19+
//! }
20+
//! }
21+
//! }
22+
//! ```
23+
//! Discrete props (rather than the generic `AsyncState<T>`) are used because
24+
//! Dioxus props must be `Clone + PartialEq`; `StudioError` is neither, and `T`
25+
//! is unconstrained — so the caller decodes the state and passes flags.
26+
27+
use dioxus::prelude::*;
28+
29+
#[derive(Clone, PartialEq, Props)]
30+
pub struct AsyncViewProps {
31+
/// True while the first fetch is pending.
32+
pub loading: bool,
33+
/// True when the fetch returned an empty result.
34+
pub empty: bool,
35+
/// Some(message) when the fetch failed; None otherwise.
36+
pub error: Option<String>,
37+
/// Whether the failed fetch is retriable (gates the Retry button).
38+
#[props(default = false)]
39+
pub retriable: bool,
40+
/// Called when the user clicks Retry (wire to Resource::restart()).
41+
#[props(default)]
42+
pub on_retry: EventHandler<()>,
43+
/// Message shown in the empty state.
44+
#[props(default = "No records.".to_string())]
45+
pub empty_message: String,
46+
}
47+
48+
// Consumed by the notification popover (01-04) and later wired views.
49+
#[component]
50+
pub fn AsyncView(props: AsyncViewProps) -> Element {
51+
if props.loading {
52+
return rsx! { div { class: "async-loading", "Loading…" } };
53+
}
54+
if let Some(msg) = props.error.clone() {
55+
return rsx! {
56+
div { class: "async-error",
57+
div { class: "async-error-msg", "{msg}" }
58+
if props.retriable {
59+
button {
60+
class: "btn",
61+
onclick: move |_| props.on_retry.call(()),
62+
"Retry"
63+
}
64+
}
65+
}
66+
};
67+
}
68+
if props.empty {
69+
return rsx! { div { class: "async-empty", "{props.empty_message}" } };
70+
}
71+
// Loaded: the caller renders its own markup; AsyncView renders nothing.
72+
rsx! {}
73+
}

nodedb-studio/src/components/command_palette.rs

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,10 @@ pub fn CommandPalette() -> Element {
6767
div { class: "palette-item", onclick: {
6868
let svc = switch_svc.clone();
6969
move |_| {
70-
if let Some(s) = svc.connect("staging-cluster") { active.set(Some(s)); }
70+
let svc = svc.clone();
71+
spawn(async move {
72+
if let Ok(s) = svc.connect("staging-cluster").await { active.set(Some(s)); }
73+
});
7174
open.set(false);
7275
}
7376
},
@@ -76,7 +79,10 @@ pub fn CommandPalette() -> Element {
7679
div { class: "palette-item", onclick: {
7780
let svc = switch_svc.clone();
7881
move |_| {
79-
if let Some(s) = svc.connect("prod-replica-eu") { active.set(Some(s)); }
82+
let svc = svc.clone();
83+
spawn(async move {
84+
if let Ok(s) = svc.connect("prod-replica-eu").await { active.set(Some(s)); }
85+
});
8086
open.set(false);
8187
}
8288
},

nodedb-studio/src/components/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
//! Reusable studio chrome components.
22
3+
pub mod async_view;
34
pub mod command_palette;
45
pub mod modal;
56
pub mod popovers;

0 commit comments

Comments
 (0)