Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions nodedb-studio/assets/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -1308,8 +1308,11 @@
/* ============================================================
Read-only indicator (topbar)
============================================================ */
/* Always shown when rendered: the topbar renders it only for a read-only
connection. The mockup instead toggled a body.readonly class from JS,
which nothing sets here, so the pill never appeared. */
.readonly-pill {
display: none;
display: inline-flex;
align-items: center; gap: 5px;
padding: 3px 8px;
border-radius: 4px;
Expand All @@ -1319,7 +1322,6 @@
background: rgba(133,79,11,0.12);
color: var(--text-warning);
}
body.readonly .readonly-pill { display: inline-flex; }
.readonly-pill .dot {
width: 5px; height: 5px;
border-radius: 50%;
Expand Down Expand Up @@ -2066,3 +2068,6 @@
border-radius: 8px;
padding: 14px 16px;
}

/* App root (app.rs): focus target for the app-wide keys. */
.studio-root:focus { outline: none; }
103 changes: 94 additions & 9 deletions nodedb-studio/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ use crate::services::connection_service::MockConnectionService;
use crate::state::connection::{ActiveConnection, ConnectError};
use crate::state::connections_registry::SavedConnection;
use crate::state::preferences::Preferences;
use crate::state::ui::ModalKind;
use crate::state::ui::{ModalKind, Popover, PrefsPane};
use crate::views::connection_manager::ConnectionManager;
use crate::views::studio_shell::Studio;

Expand All @@ -32,14 +32,21 @@ const STYLES: Asset = asset!("/assets/styles.css");
#[allow(non_snake_case)]
pub fn App() -> Element {
// The single seam to the outside world. Provided as a trait object so a
// real client can replace the mock without touching consumers.
let service: Rc<dyn Backend> = Rc::new(MockConnectionService::ready());
// real client can replace the mock without touching consumers. Built once:
// a resource re-runs with the closure from the latest render, so an
// instance per render answers those re-runs from fresh state (a
// connection switch undid mark-all-read).
let service: Rc<dyn Backend> =
use_context_provider(|| Rc::new(MockConnectionService::ready()) as Rc<dyn Backend>);
// The real-client stub's instantiability + object-safety behind the seam is
// proven by `nodedb_service::tests::stub_is_object_safe_behind_rc`, so it is
// not constructed here on every render.

use_context_provider(|| service.clone());
use_context_provider(|| Signal::new(None::<ActiveConnection>));
// not constructed here.
let mut active = use_context_provider(|| Signal::new(None::<ActiveConnection>));
// The active connection's name. Per-connection reads subscribe to this,
// not to `active`: a database pick or a failed switch also writes
// `active`, and neither changes what the connection's server answers.
let conn_name = use_memo(move || active.read().as_ref().map(|c| c.name.clone()));
use_context_provider(|| conn_name);
let mut registry = use_context_provider(|| Signal::new(Vec::<SavedConnection>::new()));
// Single source of truth for notifications: the bell badge AND the popover
// read this one store; user actions (mark-all-read / click) mutate it.
Expand All @@ -48,7 +55,11 @@ pub fn App() -> Element {
use_context_provider(|| Signal::new(Preferences::default()));
// Modal state is provided here (not in Studio) because Preferences is
// reachable while disconnected and via Cmd+, in either state.
use_context_provider(|| Signal::new(None::<ModalKind>));
let mut modal = use_context_provider(|| Signal::new(None::<ModalKind>));
// The open topbar popover and the command palette. Studio-only in use, but
// provided here so the root key handler below can open and close them.
let mut popover = use_context_provider(|| Signal::new(None::<Popover>));
let mut palette = use_context_provider(|| Signal::new(false));
// Connect failures are provided here, not inside the views that start them:
// the command palette and the switch popover both close on click, so an
// error they owned would be dropped before it could render.
Expand All @@ -64,6 +75,11 @@ pub fn App() -> Element {
let svc = service.clone();
use_resource(move || {
let svc = svc.clone();
// The feed belongs to the connection. Reading its name here
// subscribes the resource, so connecting, switching or
// disconnecting re-reads it instead of leaving the previous
// connection's bell up.
let _conn = conn_name();
async move {
// Reset to Loading at the start of each (re)load so a Retry shows
// the spinner. `.set()` holds no guard across the awaits below.
Expand All @@ -79,7 +95,70 @@ pub fn App() -> Element {
};
use_context_provider(|| reload_feed);

let active = use_context::<Signal<Option<ActiveConnection>>>();
// Every app key, in one handler on the root. A key reaches a handler only
// from a focused element inside it, and a click focuses the nearest
// focusable ancestor: this root for a click in a modal, which renders
// outside the studio's `.app`. Keys split across the two went dead
// whenever focus sat on the wrong one.
let on_key = move |e: KeyboardEvent| {
// A key pressed while an IME composes text belongs to the IME: Esc
// there cancels the candidate, not the modal around the input.
if e.is_composing() {
return;
}
let meta = e.modifiers().meta() || e.modifiers().ctrl();
let connected = active.peek().is_some();
match e.key() {
Key::Character(c) if meta && c == "k" && connected => {
e.prevent_default();
palette.set(true);
}
Key::Character(c) if meta && c == "d" && connected => {
e.prevent_default();
active.set(None);
}
// Not over an open modal: replacing it discards what was typed there.
Key::Character(c) if meta && c == "," && modal.peek().is_none() => {
e.prevent_default();
modal.set(Some(ModalKind::Preferences(PrefsPane::Appearance)));
}
Key::Escape => {
palette.set(false);
popover.set(None);
modal.set(None);
}
_ => {}
}
};
// Unmounting the focused element drops focus to `<body>`, outside this
// root, and keys go dead until the next click. Two things unmount it:
// closing the modal or palette (Esc from one of their inputs), and
// disconnecting (the studio goes). After either, focus moves back here.
let mut root = use_signal(|| None::<Rc<MountedData>>);
let focus_root = move || {
if let Some(el) = root.peek().clone() {
spawn(async move {
// Focus is best-effort: if it fails, a click anywhere in the
// window focuses this root again.
let _ = el.set_focus(true).await;
});
}
};
// Also runs once `root` mounts, which gives the app its first focus.
use_effect(move || {
let _mounted = root.read().is_some();
if modal.read().is_none() && !palette() {
focus_root();
}
});
use_effect(move || {
if conn_name.read().is_none() {
// A reconnect must not reopen these.
palette.set(false);
popover.set(None);
focus_root();
}
});
// Rendered through the same component every failed read uses, so the markup
// and styling live in one place. It is fixed-position and above the modal
// scrim: `html, body` are `100vh; overflow: hidden`, so a banner in normal
Expand All @@ -94,6 +173,11 @@ pub fn App() -> Element {

rsx! {
document::Stylesheet { href: STYLES }
div {
class: "studio-root",
tabindex: "-1",
onkeydown: on_key,
onmounted: move |e| root.set(Some(e.data())),
if let Some(msg) = connect_error_msg {
div { class: "connect-error-bar",
AsyncView { loading: false, empty: false, error: Some(msg) }
Expand All @@ -106,5 +190,6 @@ pub fn App() -> Element {
}
// Overlays everything; renders nothing when no modal is open.
ModalHost {}
}
}
}
22 changes: 22 additions & 0 deletions nodedb-studio/src/components/async_view.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,34 @@
//! if let Some(data) = state.loaded() { /* caller's own markup for `data` */ }
//! }
//! ```
//! A read that re-runs on a connection switch maps through `resource_state`
//! instead, so it shows `Loading` rather than the previous server's result.
//!
//! Discrete props (rather than the generic `AsyncState<T>`) are used because
//! Dioxus props must be `Clone + PartialEq`; `StudioError`/`T` need not be — so
//! the caller decodes the state via the accessors and passes plain flags.

use dioxus::prelude::*;

use crate::services::async_state::{AsyncState, IsEmpty};
use crate::services::error::StudioError;

/// A resource read as `AsyncState`, `Loading` while a re-run is in flight.
/// `Resource::read` keeps the previous result until the new one lands, so
/// right after a connection switch it holds the last server's answer.
/// Reading `state()` subscribes the caller, so it re-renders the moment the
/// re-run starts.
pub fn resource_state<T: IsEmpty + Clone>(
resource: &Resource<Result<T, StudioError>>,
) -> AsyncState<T> {
match *resource.state().read() {
UseResourceState::Pending => AsyncState::Loading,
UseResourceState::Ready | UseResourceState::Paused | UseResourceState::Stopped => {
AsyncState::from_value(resource.read().clone())
}
}
}

#[derive(Clone, PartialEq, Props)]
pub struct AsyncViewProps {
/// True while the first fetch is pending.
Expand Down
146 changes: 81 additions & 65 deletions nodedb-studio/src/components/command_palette.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
//! Command palette (Cmd+K). Opens only while connected.
//!
//! Rendered inside the router (via `StudioLayout`) so navigation items can use
//! the navigator. Open state is the shared `Signal<bool>` provided by `Studio`.
//! the navigator. Open state is the shared `Signal<bool>` provided by `App`.

use dioxus::core::spawn_forever;
use dioxus::prelude::*;
Expand All @@ -12,6 +12,21 @@ use crate::state::connection::{ActiveConnection, ConnectError, apply_connect};
use crate::state::connections_registry::{Credentials, SavedConnection};
use crate::state::ui::{ModalKind, PrefsPane};

/// The palette's "Switch to …" targets: every saved connection that can be
/// opened, except the one already active, in registry order, each with the
/// credentials to send. The palette switches between saved entries only, so
/// there is no username field to type into here.
fn switch_targets(
registry: &[SavedConnection],
current: Option<&str>,
) -> Vec<(String, Credentials)> {
registry
.iter()
.filter(|c| c.status.is_connectable() && Some(c.name.as_str()) != current)
.map(|c| (c.name.clone(), c.credentials()))
.collect()
}

#[component]
pub fn CommandPalette() -> Element {
let mut open = use_context::<Signal<bool>>();
Expand All @@ -28,33 +43,11 @@ pub fn CommandPalette() -> Element {
return rsx! {};
}

// Switch connection by name, then close. `service` is an Rc (not Copy), so
// each switch handler clones it.
let switch_svc = service.clone();

// A saved entry's stored profile IS its explicit username; the palette
// switches between entries that already have one, so there is no field to
// type into here. An entry with no profile yields a blank, which the seam
// rejects with MissingUsername and the app root renders — never a silent
// fallback to `admin`. The connections fixture carries the invariant
// that keeps connectable entries from reaching that state.
let creds_for = |name: &str| -> Credentials {
Credentials {
username: registry
// .read(), not .peek(): this runs at render time, not in an
// event handler. peek() would freeze the credentials at the
// render where the palette opened, so a registry that resolves
// later (a real backend awaiting the network) would leave every
// switch sending a blank username with no re-render to fix it.
.read()
.iter()
.find(|c| c.name == name)
.and_then(|c| c.profile.as_ref())
.map(|p| p.user.clone())
.unwrap_or_default(),
password: None,
}
};
// .read(), not .peek(): this runs at render time. peek() freezes the list
// at the render where the palette opened, so a registry that resolves
// later (a real backend awaiting the network) never shows its entries.
let current = active.read().as_ref().map(|c| c.name.clone());
let targets = switch_targets(&registry.read(), current.as_deref());

rsx! {
div {
Expand Down Expand Up @@ -94,43 +87,27 @@ pub fn CommandPalette() -> Element {
}

div { class: "palette-section", "Connections" }
div { class: "palette-item", onclick: {
let svc = switch_svc.clone();
let creds = creds_for("staging-cluster");
move |_| {
let svc = svc.clone();
let creds = creds.clone();
connect_error.set(ConnectError(None));
// spawn_forever: the palette closes on the next
// line, and a scope-bound task would be dropped.
spawn_forever(async move {
let result = svc.connect("staging-cluster", &creds).await;
let err = apply_connect(&mut active.write(), result);
connect_error.set(ConnectError(err));
});
open.set(false);
}
},
"Switch to staging-cluster"
}
div { class: "palette-item", onclick: {
let svc = switch_svc.clone();
let creds = creds_for("prod-replica-eu");
move |_| {
let svc = svc.clone();
let creds = creds.clone();
connect_error.set(ConnectError(None));
// spawn_forever: the palette closes on the next
// line, and a scope-bound task would be dropped.
spawn_forever(async move {
let result = svc.connect("prod-replica-eu", &creds).await;
let err = apply_connect(&mut active.write(), result);
connect_error.set(ConnectError(err));
});
open.set(false);
}
},
"Switch to prod-replica-eu"
for (name, creds) in targets {
div { key: "{name}", class: "palette-item", onclick: {
let svc = service.clone();
let name = name.clone();
move |_| {
let svc = svc.clone();
let name = name.clone();
let creds = creds.clone();
connect_error.set(ConnectError(None));
// spawn_forever: the palette closes on the next
// line, and that drops a scope-bound task.
spawn_forever(async move {
let result = svc.connect(&name, &creds).await;
let err = apply_connect(&mut active.write(), result);
connect_error.set(ConnectError(err));
});
open.set(false);
}
},
"Switch to {name}"
}
}
div { class: "palette-item", onclick: move |_| { active.set(None); open.set(false); },
"Disconnect" span { class: "meta", "⌘D" }
Expand All @@ -140,3 +117,42 @@ pub fn CommandPalette() -> Element {
}
}
}

#[cfg(test)]
mod tests {
use super::*;
use crate::services::connection_service::{ConnectionService, MockConnectionService};

/// The saved-connection registry, read through the seam as the app does.
async fn registry() -> Vec<SavedConnection> {
MockConnectionService::ready()
.list_connections()
.await
.expect("mock registry")
}

fn names(targets: &[(String, Credentials)]) -> Vec<&str> {
targets.iter().map(|(n, _)| n.as_str()).collect()
}

/// Matches the mockup while on local-nodedb-dev: the other two reachable
/// connections, not the offline one, not the current one.
#[tokio::test]
async fn lists_reachable_connections_except_the_current_one() {
let t = switch_targets(&registry().await, Some("local-nodedb-dev"));
assert_eq!(names(&t), ["staging-cluster", "prod-replica-eu"]);
}

#[tokio::test]
async fn each_target_carries_its_own_username() {
let t = switch_targets(&registry().await, Some("local-nodedb-dev"));
assert!(t.iter().all(|(_, c)| !c.username.trim().is_empty()));
}

/// The registry loads asynchronously; until it does there is nothing to
/// offer rather than a stale hardcoded list.
#[test]
fn empty_registry_offers_nothing() {
assert!(switch_targets(&[], Some("local-nodedb-dev")).is_empty());
}
}
Loading
Loading