From 3c83bb11b1868bfb0edb8f871410e53589a908e2 Mon Sep 17 00:00:00 2001 From: Orual Date: Tue, 9 Dec 2025 09:43:02 -0500 Subject: [PATCH] collab working in worker --- crates/weaver-app/public/editor_worker.js | 24 +- crates/weaver-app/public/embed_worker.js | 30 +- crates/weaver-app/src/collab_context.rs | 7 +- .../src/components/editor/actions.rs | 6 - .../src/components/editor/collab.rs | 36 +- .../src/components/editor/component.rs | 8 +- .../src/components/editor/storage.rs | 19 +- .../weaver-app/src/components/editor/sync.rs | 57 +- .../src/components/editor/worker.rs | 714 ++++++++++-------- crates/weaver-app/src/components/entry.rs | 91 --- crates/weaver-app/src/components/identity.rs | 328 +++----- crates/weaver-app/src/data.rs | 12 - crates/weaver-app/src/og/mod.rs | 55 +- crates/weaver-common/src/agent.rs | 9 +- .../weaver-common/src/transport/messages.rs | 11 +- crates/weaver-common/src/transport/node.rs | 11 +- .../weaver-common/src/transport/presence.rs | 16 +- .../src/transport/presence_types.rs | 11 +- 18 files changed, 664 insertions(+), 781 deletions(-) diff --git a/crates/weaver-app/public/editor_worker.js b/crates/weaver-app/public/editor_worker.js index ba43887..37c4f8f 100644 --- a/crates/weaver-app/public/editor_worker.js +++ b/crates/weaver-app/public/editor_worker.js @@ -232,22 +232,26 @@ let wasm_bindgen; let WASM_VECTOR_LEN = 0; - function wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke_______3_(arg0, arg1) { - wasm.wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke_______3_(arg0, arg1); + function wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke___wasm_bindgen_1add006a0ed82fd3___JsValue_____(arg0, arg1, arg2) { + wasm.wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke___wasm_bindgen_1add006a0ed82fd3___JsValue_____(arg0, arg1, arg2); } - function wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke___web_sys_1984c39bba2ffe3a___features__gen_MessageEvent__MessageEvent_____(arg0, arg1, arg2) { - wasm.wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke___web_sys_1984c39bba2ffe3a___features__gen_MessageEvent__MessageEvent_____(arg0, arg1, arg2); + function wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke_______3_(arg0, arg1) { + wasm.wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke_______3_(arg0, arg1); } - function wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke___wasm_bindgen_1add006a0ed82fd3___JsValue_____(arg0, arg1, arg2) { - wasm.wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke___wasm_bindgen_1add006a0ed82fd3___JsValue_____(arg0, arg1, arg2); + function wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke_______1_(arg0, arg1) { + wasm.wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke_______1_(arg0, arg1); } function wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke______(arg0, arg1) { wasm.wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke______(arg0, arg1); } + function wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke___web_sys_1984c39bba2ffe3a___features__gen_MessageEvent__MessageEvent_____(arg0, arg1, arg2) { + wasm.wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke___web_sys_1984c39bba2ffe3a___features__gen_MessageEvent__MessageEvent_____(arg0, arg1, arg2); + } + function wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke___web_sys_1984c39bba2ffe3a___features__gen_MessageEvent__MessageEvent______1_(arg0, arg1, arg2) { wasm.wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke___web_sys_1984c39bba2ffe3a___features__gen_MessageEvent__MessageEvent______1_(arg0, arg1, arg2); } @@ -260,10 +264,6 @@ let wasm_bindgen; wasm.wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke___web_sys_1984c39bba2ffe3a___features__gen_CloseEvent__CloseEvent_____(arg0, arg1, arg2); } - function wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke_______1_(arg0, arg1) { - wasm.wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke_______1_(arg0, arg1); - } - function wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke___wasm_bindgen_1add006a0ed82fd3___JsValue__wasm_bindgen_1add006a0ed82fd3___JsValue_____(arg0, arg1, arg2, arg3) { wasm.wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke___wasm_bindgen_1add006a0ed82fd3___JsValue__wasm_bindgen_1add006a0ed82fd3___JsValue_____(arg0, arg1, arg2, arg3); } @@ -1071,8 +1071,8 @@ let wasm_bindgen; const ret = makeMutClosure(arg0, arg1, wasm.wasm_bindgen_1add006a0ed82fd3___closure__destroy___dyn_core_b125d98f3949a913___ops__function__FnMut__web_sys_1984c39bba2ffe3a___features__gen_MessageEvent__MessageEvent____Output_______, wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke___web_sys_1984c39bba2ffe3a___features__gen_MessageEvent__MessageEvent______1_); return ret; }; - imports.wbg.__wbindgen_cast_70089f6cfcc0d4b7 = function(arg0, arg1) { - // Cast intrinsic for `Closure(Closure { dtor_idx: 391, function: Function { arguments: [NamedExternref("MessageEvent")], shim_idx: 392, ret: Unit, inner_ret: Some(Unit) }, mutable: false }) -> Externref`. + imports.wbg.__wbindgen_cast_82f5386d361eee3f = function(arg0, arg1) { + // Cast intrinsic for `Closure(Closure { dtor_idx: 441, function: Function { arguments: [NamedExternref("MessageEvent")], shim_idx: 442, ret: Unit, inner_ret: Some(Unit) }, mutable: false }) -> Externref`. const ret = makeClosure(arg0, arg1, wasm.wasm_bindgen_1add006a0ed82fd3___closure__destroy___dyn_core_b125d98f3949a913___ops__function__Fn__web_sys_1984c39bba2ffe3a___features__gen_MessageEvent__MessageEvent____Output_______, wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke___web_sys_1984c39bba2ffe3a___features__gen_MessageEvent__MessageEvent_____); return ret; }; diff --git a/crates/weaver-app/public/embed_worker.js b/crates/weaver-app/public/embed_worker.js index abf165d..3f2ecf0 100644 --- a/crates/weaver-app/public/embed_worker.js +++ b/crates/weaver-app/public/embed_worker.js @@ -232,14 +232,14 @@ let wasm_bindgen; let WASM_VECTOR_LEN = 0; - function wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke_______1_(arg0, arg1) { - wasm.wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke_______1_(arg0, arg1); - } - function wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke___wasm_bindgen_1add006a0ed82fd3___JsValue_____(arg0, arg1, arg2) { wasm.wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke___wasm_bindgen_1add006a0ed82fd3___JsValue_____(arg0, arg1, arg2); } + function wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke_______1_(arg0, arg1) { + wasm.wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke_______1_(arg0, arg1); + } + function wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke___web_sys_1984c39bba2ffe3a___features__gen_MessageEvent__MessageEvent_____(arg0, arg1, arg2) { wasm.wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke___web_sys_1984c39bba2ffe3a___features__gen_MessageEvent__MessageEvent_____(arg0, arg1, arg2); } @@ -809,24 +809,24 @@ let wasm_bindgen; const ret = getStringFromWasm0(arg0, arg1); return ret; }; - imports.wbg.__wbindgen_cast_28fcab414066dd47 = function(arg0, arg1) { - // Cast intrinsic for `Closure(Closure { dtor_idx: 2276, function: Function { arguments: [Externref], shim_idx: 2277, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. - const ret = makeMutClosure(arg0, arg1, wasm.wasm_bindgen_1add006a0ed82fd3___closure__destroy___dyn_core_b125d98f3949a913___ops__function__FnMut__wasm_bindgen_1add006a0ed82fd3___JsValue____Output_______, wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke___wasm_bindgen_1add006a0ed82fd3___JsValue_____); + imports.wbg.__wbindgen_cast_b44cc294b43af732 = function(arg0, arg1) { + // Cast intrinsic for `Closure(Closure { dtor_idx: 1192, function: Function { arguments: [], shim_idx: 1193, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. + const ret = makeMutClosure(arg0, arg1, wasm.wasm_bindgen_1add006a0ed82fd3___closure__destroy___dyn_core_b125d98f3949a913___ops__function__FnMut_____Output_______, wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke______); return ret; }; - imports.wbg.__wbindgen_cast_80e60da953ba3dd9 = function(arg0, arg1) { - // Cast intrinsic for `Closure(Closure { dtor_idx: 310, function: Function { arguments: [NamedExternref("MessageEvent")], shim_idx: 311, ret: Unit, inner_ret: Some(Unit) }, mutable: false }) -> Externref`. + imports.wbg.__wbindgen_cast_c8f3d1634b3b2a31 = function(arg0, arg1) { + // Cast intrinsic for `Closure(Closure { dtor_idx: 335, function: Function { arguments: [NamedExternref("MessageEvent")], shim_idx: 336, ret: Unit, inner_ret: Some(Unit) }, mutable: false }) -> Externref`. const ret = makeClosure(arg0, arg1, wasm.wasm_bindgen_1add006a0ed82fd3___closure__destroy___dyn_core_b125d98f3949a913___ops__function__Fn__web_sys_1984c39bba2ffe3a___features__gen_MessageEvent__MessageEvent____Output_______, wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke___web_sys_1984c39bba2ffe3a___features__gen_MessageEvent__MessageEvent_____); return ret; }; - imports.wbg.__wbindgen_cast_a731521d4dc80277 = function(arg0, arg1) { - // Cast intrinsic for `Closure(Closure { dtor_idx: 1194, function: Function { arguments: [], shim_idx: 1195, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. - const ret = makeMutClosure(arg0, arg1, wasm.wasm_bindgen_1add006a0ed82fd3___closure__destroy___dyn_core_b125d98f3949a913___ops__function__FnMut_____Output_______, wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke______); + imports.wbg.__wbindgen_cast_e60e2a067b8a256d = function(arg0, arg1) { + // Cast intrinsic for `Closure(Closure { dtor_idx: 1452, function: Function { arguments: [], shim_idx: 1453, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. + const ret = makeMutClosure(arg0, arg1, wasm.wasm_bindgen_1add006a0ed82fd3___closure__destroy___dyn_core_b125d98f3949a913___ops__function__FnMut_____Output________1_, wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke_______1_); return ret; }; - imports.wbg.__wbindgen_cast_e0273882c29884ec = function(arg0, arg1) { - // Cast intrinsic for `Closure(Closure { dtor_idx: 1454, function: Function { arguments: [], shim_idx: 1455, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. - const ret = makeMutClosure(arg0, arg1, wasm.wasm_bindgen_1add006a0ed82fd3___closure__destroy___dyn_core_b125d98f3949a913___ops__function__FnMut_____Output________1_, wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke_______1_); + imports.wbg.__wbindgen_cast_e76354026ca30a8f = function(arg0, arg1) { + // Cast intrinsic for `Closure(Closure { dtor_idx: 2274, function: Function { arguments: [Externref], shim_idx: 2275, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. + const ret = makeMutClosure(arg0, arg1, wasm.wasm_bindgen_1add006a0ed82fd3___closure__destroy___dyn_core_b125d98f3949a913___ops__function__FnMut__wasm_bindgen_1add006a0ed82fd3___JsValue____Output_______, wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke___wasm_bindgen_1add006a0ed82fd3___JsValue_____); return ret; }; imports.wbg.__wbindgen_init_externref_table = function() { diff --git a/crates/weaver-app/src/collab_context.rs b/crates/weaver-app/src/collab_context.rs index 40f6439..c2d5567 100644 --- a/crates/weaver-app/src/collab_context.rs +++ b/crates/weaver-app/src/collab_context.rs @@ -4,14 +4,15 @@ //! the CollabCoordinator component for display in the editor debug panel. use dioxus::prelude::*; +use jacquard::smol_str::SmolStr; /// Debug state for the collab session, displayed in editor debug panel. #[derive(Clone, Default)] pub struct CollabDebugState { /// Our node ID - pub node_id: Option, + pub node_id: Option, /// Our relay URL - pub relay_url: Option, + pub relay_url: Option, /// URI of our published session record pub session_record_uri: Option, /// Number of discovered peers @@ -21,7 +22,7 @@ pub struct CollabDebugState { /// Whether we've joined the gossip swarm pub is_joined: bool, /// Last error message - pub last_error: Option, + pub last_error: Option, } /// Hook to get the collab debug state signal. diff --git a/crates/weaver-app/src/components/editor/actions.rs b/crates/weaver-app/src/components/editor/actions.rs index 58e0246..82487a3 100644 --- a/crates/weaver-app/src/components/editor/actions.rs +++ b/crates/weaver-app/src/components/editor/actions.rs @@ -727,12 +727,6 @@ impl KeybindingConfig { .map(|a| a.with_range(range)) } - /// Look up an action for the given key and modifiers, with the current range applied. - #[allow(dead_code)] - pub fn lookup_key(&self, key: Key, modifiers: Modifiers, range: Range) -> Option { - self.lookup(KeyCombo::with_modifiers(key, modifiers), range) - } - /// Add or replace a keybinding. #[allow(dead_code)] pub fn bind(&mut self, combo: KeyCombo, action: EditorAction) { diff --git a/crates/weaver-app/src/components/editor/collab.rs b/crates/weaver-app/src/components/editor/collab.rs index 72c4070..9c124d4 100644 --- a/crates/weaver-app/src/components/editor/collab.rs +++ b/crates/weaver-app/src/components/editor/collab.rs @@ -15,6 +15,8 @@ use super::document::EditorDocument; use dioxus::prelude::*; +#[cfg(target_arch = "wasm32")] +use jacquard::smol_str::{format_smolstr, SmolStr}; #[cfg(target_arch = "wasm32")] use jacquard::types::string::AtUri; @@ -53,13 +55,13 @@ enum CoordinatorState { Initializing, /// Creating session record on PDS CreatingSession { - node_id: String, - relay_url: Option, + node_id: SmolStr, + relay_url: Option, }, /// Active collab session Active { session_uri: AtUri<'static> }, /// Error state - Error(String), + Error(SmolStr), } /// Coordinator component that bridges worker and PDS. @@ -147,7 +149,7 @@ pub fn CollabCoordinator(props: CollabCoordinatorProps) -> Element { // Initialize worker with current document snapshot let snapshot = doc.export_snapshot(); - let draft_key = resource_uri.clone(); // Use resource URI as the key + let draft_key: SmolStr = resource_uri.clone().into(); // Use resource URI as the key spawn(async move { if let Some(ref mut sink) = *worker_sink.write() { if let Err(e) = sink @@ -227,7 +229,7 @@ pub fn CollabCoordinator(props: CollabCoordinatorProps) -> Element { let uri = match AtUri::new(&resource_uri) { Ok(u) => u.into_static(), Err(e) => { - let err = format!("Invalid resource URI: {e}"); + let err = format_smolstr!("Invalid resource URI: {e}"); debug_state .with_mut(|ds| ds.last_error = Some(err.clone())); state.set(CoordinatorState::Error(err)); @@ -239,7 +241,7 @@ pub fn CollabCoordinator(props: CollabCoordinatorProps) -> Element { let strong_ref = match fetcher.confirm_record_ref(&uri).await { Ok(r) => r, Err(e) => { - let err = format!("Failed to get resource ref: {e}"); + let err = format_smolstr!("Failed to get resource ref: {e}"); debug_state .with_mut(|ds| ds.last_error = Some(err.clone())); state.set(CoordinatorState::Error(err)); @@ -322,7 +324,7 @@ pub fn CollabCoordinator(props: CollabCoordinatorProps) -> Element { }); } Err(e) => { - let err = format!("Failed to create session: {e}"); + let err = format_smolstr!("Failed to create session: {e}"); debug_state .with_mut(|ds| ds.last_error = Some(err.clone())); state.set(CoordinatorState::Error(err)); @@ -367,30 +369,30 @@ pub fn CollabCoordinator(props: CollabCoordinatorProps) -> Element { let fetcher = fetcher.clone(); // Get our profile info and send BroadcastJoin - let (our_did, our_display_name) = match fetcher.current_did().await { + let (our_did, our_display_name): (SmolStr, SmolStr) = match fetcher.current_did().await { Some(did) => { - let display_name = match fetcher.fetch_profile(&did.clone().into()).await { + let display_name: SmolStr = match fetcher.fetch_profile(&did.clone().into()).await { Ok(profile) => { match &profile.inner { ProfileDataViewInner::ProfileView(p) => { - p.display_name.as_ref().map(|s| s.to_string()).unwrap_or_else(|| did.to_string()) + p.display_name.as_ref().map(|s| s.as_ref().into()).unwrap_or_else(|| did.as_ref().into()) } ProfileDataViewInner::ProfileViewDetailed(p) => { - p.display_name.as_ref().map(|s| s.to_string()).unwrap_or_else(|| did.to_string()) + p.display_name.as_ref().map(|s| s.as_ref().into()).unwrap_or_else(|| did.as_ref().into()) } ProfileDataViewInner::TangledProfileView(p) => { - p.handle.to_string() + p.handle.as_ref().into() } - _ => did.to_string(), + _ => did.as_ref().into(), } } - Err(_) => did.to_string(), + Err(_) => did.as_ref().into(), }; - (did.to_string(), display_name) + (did.as_ref().into(), display_name) } None => { tracing::warn!("CollabCoordinator: no current DID for Join message"); - ("unknown".to_string(), "Anonymous".to_string()) + ("unknown".into(), "Anonymous".into()) } }; @@ -471,7 +473,7 @@ pub fn CollabCoordinator(props: CollabCoordinatorProps) -> Element { Ok(peers) => { debug_state.with_mut(|ds| ds.discovered_peers = peers.len()); if !peers.is_empty() { - let peer_ids: Vec = + let peer_ids: Vec = peers.into_iter().map(|p| p.node_id).collect(); if let Some(ref mut s) = *worker_sink.write() { diff --git a/crates/weaver-app/src/components/editor/component.rs b/crates/weaver-app/src/components/editor/component.rs index 4a22766..fc31122 100644 --- a/crates/weaver-app/src/components/editor/component.rs +++ b/crates/weaver-app/src/components/editor/component.rs @@ -4,7 +4,7 @@ use dioxus::prelude::*; use jacquard::IntoStatic; use jacquard::cowstr::ToCowStr; use jacquard::identity::resolver::IdentityResolver; -use jacquard::smol_str::SmolStr; +use jacquard::smol_str::{SmolStr, ToSmolStr}; use jacquard::types::aturi::AtUri; use jacquard::types::blob::BlobRef; use jacquard::types::ident::AtIdentifier; @@ -790,7 +790,7 @@ fn MarkdownEditorInner( let _ = sink .send(WorkerInput::Init { snapshot, - draft_key, + draft_key: draft_key.into(), }) .await; } @@ -846,8 +846,8 @@ fn MarkdownEditorInner( }; let cursor_offset = doc.cursor.read().offset; - let editing_uri = doc.entry_ref().map(|r| r.uri.to_string()); - let editing_cid = doc.entry_ref().map(|r| r.cid.to_string()); + let editing_uri = doc.entry_ref().map(|r| r.uri.to_smolstr()); + let editing_cid = doc.entry_ref().map(|r| r.cid.to_smolstr()); let sink_clone = worker_sink.clone(); diff --git a/crates/weaver-app/src/components/editor/storage.rs b/crates/weaver-app/src/components/editor/storage.rs index cd92d0d..e0a0bcd 100644 --- a/crates/weaver-app/src/components/editor/storage.rs +++ b/crates/weaver-app/src/components/editor/storage.rs @@ -20,6 +20,7 @@ use dioxus::prelude::*; #[cfg(all(target_family = "wasm", target_os = "unknown"))] use gloo_storage::{LocalStorage, Storage}; use jacquard::IntoStatic; +use jacquard::smol_str::{SmolStr, ToSmolStr}; use jacquard::types::string::{AtUri, Cid}; use weaver_api::com_atproto::repo::strong_ref::StrongRef; use loro::cursor::Cursor; @@ -49,7 +50,7 @@ pub struct EditorSnapshot { /// Entry title (for debugging/display in drafts list) #[serde(default)] - pub title: String, + pub title: SmolStr, /// Base64-encoded CRDT snapshot (contains ALL fields including embeds) #[serde(default, skip_serializing_if = "Option::is_none")] @@ -65,11 +66,11 @@ pub struct EditorSnapshot { /// AT-URI if editing an existing entry (None for new entries) #[serde(default, skip_serializing_if = "Option::is_none")] - pub editing_uri: Option, + pub editing_uri: Option, /// CID of the entry if editing an existing entry #[serde(default, skip_serializing_if = "Option::is_none")] - pub editing_cid: Option, + pub editing_cid: Option, } /// Build the full storage key from a draft key. @@ -97,12 +98,12 @@ pub fn save_to_storage( let snapshot = EditorSnapshot { content: doc.content(), - title: doc.title(), + title: doc.title().into(), snapshot: snapshot_b64, cursor: doc.loro_cursor().cloned(), cursor_offset: doc.cursor.read().offset, - editing_uri: doc.entry_ref().map(|r| r.uri.to_string()), - editing_cid: doc.entry_ref().map(|r| r.cid.to_string()), + editing_uri: doc.entry_ref().map(|r| r.uri.to_smolstr()), + editing_cid: doc.entry_ref().map(|r| r.cid.to_smolstr()), }; let write_start = crate::perf::now(); @@ -235,7 +236,11 @@ pub fn list_drafts() -> Vec<(String, String, Option)> { // Try to load just the metadata if let Ok(snapshot) = LocalStorage::get::(&key) { let draft_key = key.strip_prefix(DRAFT_KEY_PREFIX).unwrap_or(&key); - drafts.push((draft_key.to_string(), snapshot.title, snapshot.editing_uri)); + drafts.push(( + draft_key.to_string(), + snapshot.title.to_string(), + snapshot.editing_uri.map(|s| s.to_string()), + )); } } } diff --git a/crates/weaver-app/src/components/editor/sync.rs b/crates/weaver-app/src/components/editor/sync.rs index e03263b..1b1dd15 100644 --- a/crates/weaver-app/src/components/editor/sync.rs +++ b/crates/weaver-app/src/components/editor/sync.rs @@ -239,20 +239,6 @@ fn build_doc_ref( } } -/// Extract (authority, rkey) from a canonical draft key (synthetic AT-URI). -/// -/// Parses `at://{authority}/sh.weaver.edit.draft/{rkey}` and returns the components. -/// Authority can be a DID or handle. -#[allow(dead_code)] -pub fn parse_draft_key( - draft_key: &str, -) -> Option<(jacquard::types::ident::AtIdentifier<'static>, String)> { - let uri = AtUri::new(draft_key).ok()?; - let authority = uri.authority().clone().into_static(); - let rkey = uri.rkey()?.0.as_str().to_string(); - Some((authority, rkey)) -} - /// Result of a sync operation. #[derive(Clone, Debug)] pub enum SyncResult { @@ -270,40 +256,6 @@ pub enum SyncResult { NoChanges, } -/// Find the edit root for an entry using constellation backlinks. -/// -/// Queries constellation for `sh.weaver.edit.root` records that reference -/// the given entry URI via the `.doc.value.entry.uri` path. -#[allow(dead_code)] -pub async fn find_edit_root_for_entry( - fetcher: &Fetcher, - entry_uri: &AtUri<'_>, -) -> Result>, WeaverError> { - let constellation_url = Url::parse(CONSTELLATION_URL) - .map_err(|e| WeaverError::InvalidNotebook(format!("Invalid constellation URL: {}", e)))?; - - let query = GetBacklinksQuery { - subject: Uri::At(entry_uri.clone().into_static()), - source: format_smolstr!("{}:doc.value.entry.uri", ROOT_NSID).into(), - cursor: None, - did: vec![], - limit: 1, - }; - - let response = fetcher - .client - .xrpc(constellation_url) - .send(&query) - .await - .map_err(|e| WeaverError::InvalidNotebook(format!("Constellation query failed: {}", e)))?; - - let output = response.into_output().map_err(|e| { - WeaverError::InvalidNotebook(format!("Failed to parse constellation response: {}", e)) - })?; - - Ok(output.records.into_iter().next().map(|r| r.into_static())) -} - /// Find ALL edit.root records across collaborators for an entry. /// /// 1. Gets list of collaborators via permissions @@ -551,7 +503,6 @@ pub async fn list_drafts_from_pds(fetcher: &Fetcher) -> Result, } /// Find all diffs for a root record using constellation backlinks. -#[allow(dead_code)] pub async fn find_diffs_for_root( fetcher: &Fetcher, root_uri: &AtUri<'_>, @@ -916,8 +867,12 @@ pub async fn load_edit_state_from_pds( fetcher: &Fetcher, entry_uri: &AtUri<'_>, ) -> Result, WeaverError> { - // Find the edit root for this entry - let root_id = match find_edit_root_for_entry(fetcher, entry_uri).await? { + // Find the edit root for this entry (take first if multiple exist) + let root_id = match find_all_edit_roots_for_entry(fetcher, entry_uri) + .await? + .into_iter() + .next() + { Some(id) => id, None => return Ok(None), }; diff --git a/crates/weaver-app/src/components/editor/worker.rs b/crates/weaver-app/src/components/editor/worker.rs index 2674697..e54dedc 100644 --- a/crates/weaver-app/src/components/editor/worker.rs +++ b/crates/weaver-app/src/components/editor/worker.rs @@ -18,6 +18,8 @@ use weaver_common::transport::PresenceSnapshot; #[cfg(all(target_family = "wasm", target_os = "unknown"))] use jacquard::smol_str::format_smolstr; +use jacquard::smol_str::SmolStr; + /// Input messages to the editor worker. #[derive(Serialize, Deserialize, Debug, Clone)] pub enum WorkerInput { @@ -26,7 +28,7 @@ pub enum WorkerInput { /// Full Loro snapshot bytes snapshot: Vec, /// Draft key for storage - draft_key: String, + draft_key: SmolStr, }, /// Apply incremental Loro updates to the shadow document. ApplyUpdates { @@ -38,16 +40,16 @@ pub enum WorkerInput { /// Current cursor position (for snapshot metadata) cursor_offset: usize, /// Editing URI if editing existing entry - editing_uri: Option, + editing_uri: Option, /// Editing CID if editing existing entry - editing_cid: Option, + editing_cid: Option, }, /// Start collab session (worker will spawn CollabNode) StartCollab { /// blake3 hash of resource URI (32 bytes) topic: [u8; 32], /// Bootstrap peer node IDs (z-base32 strings) - bootstrap_peers: Vec, + bootstrap_peers: Vec, }, /// Loro updates from local edits (forward to gossip) BroadcastUpdate { @@ -57,14 +59,14 @@ pub enum WorkerInput { /// New peers discovered by main thread AddPeers { /// Node ID strings - peers: Vec, + peers: Vec, }, /// Announce ourselves to peers (sent after AddPeers) BroadcastJoin { /// Our DID - did: String, + did: SmolStr, /// Our display name - display_name: String, + display_name: SmolStr, }, /// Local cursor position changed BroadcastCursor { @@ -85,32 +87,32 @@ pub enum WorkerOutput { /// Snapshot export completed. Snapshot { /// Draft key for storage - draft_key: String, + draft_key: SmolStr, /// Base64-encoded Loro snapshot b64_snapshot: String, /// Human-readable content (for debugging) content: String, /// Entry title - title: String, + title: SmolStr, /// Cursor offset cursor_offset: usize, /// Editing URI - editing_uri: Option, + editing_uri: Option, /// Editing CID - editing_cid: Option, + editing_cid: Option, /// Export timing in ms export_ms: f64, /// Encode timing in ms encode_ms: f64, }, /// Error occurred. - Error { message: String }, + Error { message: SmolStr }, /// Collab node ready, here's info for session record CollabReady { /// Node ID (z-base32 string) - node_id: String, + node_id: SmolStr, /// Relay URL for browser connectivity - relay_url: Option, + relay_url: Option, }, /// Collab session joined successfully CollabJoined, @@ -132,9 +134,11 @@ mod worker_impl { use super::*; use futures_util::sink::SinkExt; use futures_util::stream::StreamExt; - use gloo_worker::reactor::{reactor, ReactorScope}; + use gloo_worker::reactor::{ReactorScope, reactor}; use weaver_common::transport::CollaboratorInfo; + #[cfg(feature = "collab-worker")] + use jacquard::smol_str::ToSmolStr; #[cfg(feature = "collab-worker")] use std::sync::Arc; #[cfg(feature = "collab-worker")] @@ -155,7 +159,7 @@ mod worker_impl { #[reactor] pub async fn EditorReactor(mut scope: ReactorScope) { let mut doc: Option = None; - let mut draft_key = String::new(); + let mut draft_key = SmolStr::default(); // Collab state (only used when collab-worker feature enabled) #[cfg(feature = "collab-worker")] @@ -196,8 +200,11 @@ mod worker_impl { } } CollabEvent::PresenceChanged(snapshot) => { - if let Err(e) = scope.send(WorkerOutput::PresenceUpdate(snapshot)).await { - tracing::error!("Failed to send PresenceUpdate to coordinator: {e}"); + if let Err(e) = scope.send(WorkerOutput::PresenceUpdate(snapshot)).await + { + tracing::error!( + "Failed to send PresenceUpdate to coordinator: {e}" + ); } } CollabEvent::PeerConnected => { @@ -218,286 +225,355 @@ mod worker_impl { // Fall through to message handling below tracing::debug!(?msg, "Worker: received message"); match msg { - WorkerInput::Init { - snapshot, - draft_key: key, - } => { - let new_doc = loro::LoroDoc::new(); - if !snapshot.is_empty() { - if let Err(e) = new_doc.import(&snapshot) { - if let Err(send_err) = scope - .send(WorkerOutput::Error { - message: format_smolstr!("Failed to import snapshot: {e}").to_string(), - }) - .await - { - tracing::error!("Failed to send Error to coordinator: {send_err}"); + WorkerInput::Init { + snapshot, + draft_key: key, + } => { + let new_doc = loro::LoroDoc::new(); + if !snapshot.is_empty() { + if let Err(e) = new_doc.import(&snapshot) { + if let Err(send_err) = scope + .send(WorkerOutput::Error { + message: format_smolstr!( + "Failed to import snapshot: {e}" + ), + }) + .await + { + tracing::error!( + "Failed to send Error to coordinator: {send_err}" + ); + } + continue; + } + } + doc = Some(new_doc); + draft_key = key; + if let Err(e) = scope.send(WorkerOutput::Ready).await { + tracing::error!("Failed to send Ready to coordinator: {e}"); } - continue; - } - } - doc = Some(new_doc); - draft_key = key; - if let Err(e) = scope.send(WorkerOutput::Ready).await { - tracing::error!("Failed to send Ready to coordinator: {e}"); - } - } - - WorkerInput::ApplyUpdates { updates } => { - if let Some(ref doc) = doc { - if let Err(e) = doc.import(&updates) { - tracing::warn!("Worker failed to import updates: {e}"); } - } - } - WorkerInput::ExportSnapshot { - cursor_offset, - editing_uri, - editing_cid, - } => { - let Some(ref doc) = doc else { - if let Err(e) = scope - .send(WorkerOutput::Error { - message: "No document initialized".into(), - }) - .await - { - tracing::error!("Failed to send Error to coordinator: {e}"); - } - continue; - }; - - let export_start = crate::perf::now(); - let snapshot_bytes = match doc.export(loro::ExportMode::Snapshot) { - Ok(bytes) => bytes, - Err(e) => { - if let Err(send_err) = scope - .send(WorkerOutput::Error { - message: format_smolstr!("Export failed: {e}").to_string(), - }) - .await - { - tracing::error!("Failed to send Error to coordinator: {send_err}"); + WorkerInput::ApplyUpdates { updates } => { + if let Some(ref doc) = doc { + if let Err(e) = doc.import(&updates) { + tracing::warn!("Worker failed to import updates: {e}"); + } } - continue; } - }; - let export_ms = crate::perf::now() - export_start; - - let encode_start = crate::perf::now(); - let b64_snapshot = BASE64.encode(&snapshot_bytes); - let encode_ms = crate::perf::now() - encode_start; - - let content = doc.get_text("content").to_string(); - let title = doc.get_text("title").to_string(); - - if let Err(e) = scope - .send(WorkerOutput::Snapshot { - draft_key: draft_key.clone(), - b64_snapshot, - content, - title, + + WorkerInput::ExportSnapshot { cursor_offset, editing_uri, editing_cid, - export_ms, - encode_ms, - }) - .await - { - tracing::error!("Failed to send Snapshot to coordinator: {e}"); - } - } + } => { + let Some(ref doc) = doc else { + if let Err(e) = scope + .send(WorkerOutput::Error { + message: "No document initialized".into(), + }) + .await + { + tracing::error!("Failed to send Error to coordinator: {e}"); + } + continue; + }; - // ============================================================ - // Collab handlers - full impl when collab-worker feature enabled - // ============================================================ - #[cfg(feature = "collab-worker")] - WorkerInput::StartCollab { - topic, - bootstrap_peers, - } => { - // Spawn CollabNode - let node = match CollabNode::spawn(None).await { - Ok(n) => n, - Err(e) => { - if let Err(send_err) = scope - .send(WorkerOutput::Error { - message: format_smolstr!("Failed to spawn CollabNode: {e}").to_string(), + let export_start = crate::perf::now(); + let snapshot_bytes = match doc.export(loro::ExportMode::Snapshot) { + Ok(bytes) => bytes, + Err(e) => { + if let Err(send_err) = scope + .send(WorkerOutput::Error { + message: format_smolstr!("Export failed: {e}"), + }) + .await + { + tracing::error!( + "Failed to send Error to coordinator: {send_err}" + ); + } + continue; + } + }; + let export_ms = crate::perf::now() - export_start; + + let encode_start = crate::perf::now(); + let b64_snapshot = BASE64.encode(&snapshot_bytes); + let encode_ms = crate::perf::now() - encode_start; + + let content = doc.get_text("content").to_string(); + let title: SmolStr = doc.get_text("title").to_string().into(); + + if let Err(e) = scope + .send(WorkerOutput::Snapshot { + draft_key: draft_key.clone(), + b64_snapshot, + content, + title, + cursor_offset, + editing_uri, + editing_cid, + export_ms, + encode_ms, }) .await { - tracing::error!("Failed to send Error to coordinator: {send_err}"); + tracing::error!("Failed to send Snapshot to coordinator: {e}"); } - continue; } - }; - - // Wait for relay connection - let relay_url = node.wait_for_relay().await; - let node_id = node.node_id_string(); - - // Send ready so main thread can create session record - if let Err(e) = scope - .send(WorkerOutput::CollabReady { - node_id, - relay_url: Some(relay_url), - }) - .await - { - tracing::error!("Failed to send CollabReady to coordinator: {e}"); - } - collab_node = Some(node.clone()); - - // Parse bootstrap peers - let peers: Vec<_> = bootstrap_peers - .iter() - .filter_map(|s| parse_node_id(s).ok()) - .collect(); - - // Join gossip session - let topic_id = TopicId::from_bytes(topic); - match CollabSession::join(node, topic_id, peers).await { - Ok((session, mut events)) => { - let session = Arc::new(session); - collab_session = Some(session.clone()); - if let Err(e) = scope.send(WorkerOutput::CollabJoined).await { - tracing::error!("Failed to send CollabJoined to coordinator: {e}"); - } + // ============================================================ + // Collab handlers - full impl when collab-worker feature enabled + // ============================================================ + #[cfg(feature = "collab-worker")] + WorkerInput::StartCollab { + topic, + bootstrap_peers, + } => { + // Spawn CollabNode + let node = match CollabNode::spawn(None).await { + Ok(n) => n, + Err(e) => { + if let Err(send_err) = scope + .send(WorkerOutput::Error { + message: format_smolstr!( + "Failed to spawn CollabNode: {e}" + ), + }) + .await + { + tracing::error!( + "Failed to send Error to coordinator: {send_err}" + ); + } + continue; + } + }; - // NOTE: Don't broadcast Join here - wait for BroadcastJoin message - // after peers have been added via AddPeers + // Wait for relay connection + let relay_url = node.wait_for_relay().await; + let node_id = node.node_id_string(); - // Create channel for events from spawned task - let (event_tx, event_rx) = tokio::sync::mpsc::unbounded_channel(); - collab_event_rx = Some(event_rx); + // Send ready so main thread can create session record + if let Err(e) = scope + .send(WorkerOutput::CollabReady { + node_id, + relay_url: Some(relay_url), + }) + .await + { + tracing::error!("Failed to send CollabReady to coordinator: {e}"); + } - // Spawn event handler task that sends via channel - wasm_bindgen_futures::spawn_local(async move { - let mut presence = PresenceTracker::new(); + collab_node = Some(node.clone()); + + // Parse bootstrap peers + let peers: Vec<_> = bootstrap_peers + .iter() + .filter_map(|s| parse_node_id(s).ok()) + .collect(); + + // Join gossip session + let topic_id = TopicId::from_bytes(topic); + match CollabSession::join(node, topic_id, peers).await { + Ok((session, mut events)) => { + let session = Arc::new(session); + collab_session = Some(session.clone()); + if let Err(e) = scope.send(WorkerOutput::CollabJoined).await { + tracing::error!( + "Failed to send CollabJoined to coordinator: {e}" + ); + } - while let Some(Ok(event)) = events.next().await { - match event { - SessionEvent::Message { from, message } => { - match message { - CollabMessage::LoroUpdate { data, .. } => { - if event_tx.send(CollabEvent::RemoteUpdates { data }).is_err() { - tracing::warn!("Collab event channel closed"); - return; - } - } - CollabMessage::Join { did, display_name } => { - tracing::info!(%from, %did, %display_name, "Received Join message"); - presence.add_collaborator(from, did, display_name); - if event_tx.send(CollabEvent::PresenceChanged( - presence_to_snapshot(&presence), - )).is_err() { - tracing::warn!("Collab event channel closed"); - return; + // NOTE: Don't broadcast Join here - wait for BroadcastJoin message + // after peers have been added via AddPeers + + // Create channel for events from spawned task + let (event_tx, event_rx) = + tokio::sync::mpsc::unbounded_channel(); + collab_event_rx = Some(event_rx); + + // Spawn event handler task that sends via channel + wasm_bindgen_futures::spawn_local(async move { + let mut presence = PresenceTracker::new(); + + while let Some(Ok(event)) = events.next().await { + match event { + SessionEvent::Message { from, message } => { + match message { + CollabMessage::LoroUpdate { + data, .. + } => { + if event_tx + .send(CollabEvent::RemoteUpdates { + data, + }) + .is_err() + { + tracing::warn!( + "Collab event channel closed" + ); + return; + } + } + CollabMessage::Join { + did, + display_name, + } => { + tracing::info!(%from, %did, %display_name, "Received Join message"); + presence.add_collaborator( + from, + did, + display_name, + ); + if event_tx + .send(CollabEvent::PresenceChanged( + presence_to_snapshot(&presence), + )) + .is_err() + { + tracing::warn!( + "Collab event channel closed" + ); + return; + } + } + CollabMessage::Leave { .. } => { + presence.remove_collaborator(&from); + if event_tx + .send(CollabEvent::PresenceChanged( + presence_to_snapshot(&presence), + )) + .is_err() + { + tracing::warn!( + "Collab event channel closed" + ); + return; + } + } + CollabMessage::Cursor { + position, + selection, + .. + } => { + // Note: cursor updates require the collaborator to exist + // (added via Join message) + let exists = presence.contains(&from); + tracing::debug!(%from, position, ?selection, exists, "Received Cursor message"); + presence.update_cursor( + &from, position, selection, + ); + if event_tx + .send(CollabEvent::PresenceChanged( + presence_to_snapshot(&presence), + )) + .is_err() + { + tracing::warn!( + "Collab event channel closed" + ); + return; + } + } + _ => {} } } - CollabMessage::Leave { .. } => { - presence.remove_collaborator(&from); - if event_tx.send(CollabEvent::PresenceChanged( - presence_to_snapshot(&presence), - )).is_err() { - tracing::warn!("Collab event channel closed"); + SessionEvent::PeerJoined(peer) => { + tracing::info!(%peer, "PeerJoined - notifying coordinator"); + // Notify coordinator so it can send BroadcastJoin + // Don't add to presence yet - wait for their Join message + if event_tx + .send(CollabEvent::PeerConnected) + .is_err() + { + tracing::warn!( + "Collab event channel closed" + ); return; } } - CollabMessage::Cursor { - position, - selection, - .. - } => { - // Note: cursor updates require the collaborator to exist - // (added via Join message) - let exists = presence.contains(&from); - tracing::debug!(%from, position, ?selection, exists, "Received Cursor message"); - presence.update_cursor(&from, position, selection); - if event_tx.send(CollabEvent::PresenceChanged( - presence_to_snapshot(&presence), - )).is_err() { - tracing::warn!("Collab event channel closed"); + SessionEvent::PeerLeft(peer) => { + presence.remove_collaborator(&peer); + if event_tx + .send(CollabEvent::PresenceChanged( + presence_to_snapshot(&presence), + )) + .is_err() + { + tracing::warn!( + "Collab event channel closed" + ); return; } } - _ => {} - } - } - SessionEvent::PeerJoined(peer) => { - tracing::info!(%peer, "PeerJoined - notifying coordinator"); - // Notify coordinator so it can send BroadcastJoin - // Don't add to presence yet - wait for their Join message - if event_tx.send(CollabEvent::PeerConnected).is_err() { - tracing::warn!("Collab event channel closed"); - return; - } - } - SessionEvent::PeerLeft(peer) => { - presence.remove_collaborator(&peer); - if event_tx.send(CollabEvent::PresenceChanged( - presence_to_snapshot(&presence), - )).is_err() { - tracing::warn!("Collab event channel closed"); - return; + SessionEvent::Joined => {} } } - SessionEvent::Joined => {} + }); + } + Err(e) => { + if let Err(send_err) = scope + .send(WorkerOutput::Error { + message: format_smolstr!("Failed to join session: {e}"), + }) + .await + { + tracing::error!( + "Failed to send Error to coordinator: {send_err}" + ); } } - }); - } - Err(e) => { - if let Err(send_err) = scope - .send(WorkerOutput::Error { - message: format_smolstr!("Failed to join session: {e}").to_string(), - }) - .await - { - tracing::error!("Failed to send Error to coordinator: {send_err}"); } } - } - } - #[cfg(feature = "collab-worker")] - WorkerInput::BroadcastUpdate { data } => { - if let Some(ref session) = collab_session { - let msg = CollabMessage::LoroUpdate { - data, - version: vec![], - }; - if let Err(e) = session.broadcast(&msg).await { - tracing::warn!("Broadcast failed: {e}"); + #[cfg(feature = "collab-worker")] + WorkerInput::BroadcastUpdate { data } => { + if let Some(ref session) = collab_session { + let msg = CollabMessage::LoroUpdate { + data, + version: vec![], + }; + if let Err(e) = session.broadcast(&msg).await { + tracing::warn!("Broadcast failed: {e}"); + } + } } - } - } - #[cfg(feature = "collab-worker")] - WorkerInput::BroadcastCursor { position, selection } => { - if let Some(ref session) = collab_session { - tracing::debug!(position, ?selection, "Worker: broadcasting cursor"); - let msg = CollabMessage::Cursor { + #[cfg(feature = "collab-worker")] + WorkerInput::BroadcastCursor { position, selection, - color: OUR_COLOR, - }; - if let Err(e) = session.broadcast(&msg).await { - tracing::warn!("Cursor broadcast failed: {e}"); + } => { + if let Some(ref session) = collab_session { + tracing::debug!( + position, + ?selection, + "Worker: broadcasting cursor" + ); + let msg = CollabMessage::Cursor { + position, + selection, + color: OUR_COLOR, + }; + if let Err(e) = session.broadcast(&msg).await { + tracing::warn!("Cursor broadcast failed: {e}"); + } + } else { + tracing::debug!( + position, + ?selection, + "Worker: BroadcastCursor but no session" + ); + } } - } else { - tracing::debug!(position, ?selection, "Worker: BroadcastCursor but no session"); - } - } - #[cfg(feature = "collab-worker")] - WorkerInput::AddPeers { peers } => { - tracing::info!(count = peers.len(), "Worker: received AddPeers"); - if let Some(ref session) = collab_session { - let peer_ids: Vec<_> = peers + #[cfg(feature = "collab-worker")] + WorkerInput::AddPeers { peers } => { + tracing::info!(count = peers.len(), "Worker: received AddPeers"); + if let Some(ref session) = collab_session { + let peer_ids: Vec<_> = peers .iter() .filter_map(|s| { match parse_node_id(s) { @@ -509,35 +585,37 @@ mod worker_impl { } }) .collect(); - tracing::info!(parsed_count = peer_ids.len(), "Worker: joining peers"); - if let Err(e) = session.join_peers(peer_ids).await { - tracing::warn!("Failed to add peers: {e}"); + tracing::info!( + parsed_count = peer_ids.len(), + "Worker: joining peers" + ); + if let Err(e) = session.join_peers(peer_ids).await { + tracing::warn!("Failed to add peers: {e}"); + } + } else { + tracing::warn!("Worker: AddPeers but no collab_session"); + } } - } else { - tracing::warn!("Worker: AddPeers but no collab_session"); - } - } - #[cfg(feature = "collab-worker")] - WorkerInput::BroadcastJoin { did, display_name } => { - if let Some(ref session) = collab_session { - let join_msg = CollabMessage::Join { did, display_name }; - if let Err(e) = session.broadcast(&join_msg).await { - tracing::warn!("Failed to broadcast Join: {e}"); + #[cfg(feature = "collab-worker")] + WorkerInput::BroadcastJoin { did, display_name } => { + if let Some(ref session) = collab_session { + let join_msg = CollabMessage::Join { did, display_name }; + if let Err(e) = session.broadcast(&join_msg).await { + tracing::warn!("Failed to broadcast Join: {e}"); + } + } } - } - } - - #[cfg(feature = "collab-worker")] - WorkerInput::StopCollab => { - collab_session = None; - collab_node = None; - collab_event_rx = None; - if let Err(e) = scope.send(WorkerOutput::CollabStopped).await { - tracing::error!("Failed to send CollabStopped to coordinator: {e}"); - } - } + #[cfg(feature = "collab-worker")] + WorkerInput::StopCollab => { + collab_session = None; + collab_node = None; + collab_event_rx = None; + if let Err(e) = scope.send(WorkerOutput::CollabStopped).await { + tracing::error!("Failed to send CollabStopped to coordinator: {e}"); + } + } } // end match msg } // end RaceResult::CoordinatorMsg(Some(msg)) } // end match race_result @@ -548,17 +626,22 @@ mod worker_impl { let Some(msg) = scope.next().await else { break }; tracing::debug!(?msg, "Worker: received message"); match msg { - WorkerInput::Init { snapshot, draft_key: key } => { + WorkerInput::Init { + snapshot, + draft_key: key, + } => { let new_doc = loro::LoroDoc::new(); if !snapshot.is_empty() { if let Err(e) = new_doc.import(&snapshot) { if let Err(send_err) = scope .send(WorkerOutput::Error { - message: format_smolstr!("Failed to import snapshot: {e}").to_string(), + message: format_smolstr!("Failed to import snapshot: {e}"), }) .await { - tracing::error!("Failed to send Error to coordinator: {send_err}"); + tracing::error!( + "Failed to send Error to coordinator: {send_err}" + ); } continue; } @@ -576,9 +659,18 @@ mod worker_impl { } } } - WorkerInput::ExportSnapshot { cursor_offset, editing_uri, editing_cid } => { + WorkerInput::ExportSnapshot { + cursor_offset, + editing_uri, + editing_cid, + } => { let Some(ref doc) = doc else { - if let Err(e) = scope.send(WorkerOutput::Error { message: "No document initialized".into() }).await { + if let Err(e) = scope + .send(WorkerOutput::Error { + message: "No document initialized".into(), + }) + .await + { tracing::error!("Failed to send Error to coordinator: {e}"); } continue; @@ -587,8 +679,15 @@ mod worker_impl { let snapshot_bytes = match doc.export(loro::ExportMode::Snapshot) { Ok(bytes) => bytes, Err(e) => { - if let Err(send_err) = scope.send(WorkerOutput::Error { message: format_smolstr!("Export failed: {e}").to_string() }).await { - tracing::error!("Failed to send Error to coordinator: {send_err}"); + if let Err(send_err) = scope + .send(WorkerOutput::Error { + message: format_smolstr!("Export failed: {e}"), + }) + .await + { + tracing::error!( + "Failed to send Error to coordinator: {send_err}" + ); } continue; } @@ -598,17 +697,32 @@ mod worker_impl { let b64_snapshot = BASE64.encode(&snapshot_bytes); let encode_ms = crate::perf::now() - encode_start; let content = doc.get_text("content").to_string(); - let title = doc.get_text("title").to_string(); - if let Err(e) = scope.send(WorkerOutput::Snapshot { - draft_key: draft_key.clone(), b64_snapshot, content, title, - cursor_offset, editing_uri, editing_cid, export_ms, encode_ms, - }).await { + let title: SmolStr = doc.get_text("title").to_string().into(); + if let Err(e) = scope + .send(WorkerOutput::Snapshot { + draft_key: draft_key.clone(), + b64_snapshot, + content, + title, + cursor_offset, + editing_uri, + editing_cid, + export_ms, + encode_ms, + }) + .await + { tracing::error!("Failed to send Snapshot to coordinator: {e}"); } } // Collab stubs for non-collab-worker build WorkerInput::StartCollab { .. } => { - if let Err(e) = scope.send(WorkerOutput::Error { message: "Collab not enabled".into() }).await { + if let Err(e) = scope + .send(WorkerOutput::Error { + message: "Collab not enabled".into(), + }) + .await + { tracing::error!("Failed to send Error to coordinator: {e}"); } } @@ -632,7 +746,7 @@ mod worker_impl { let collaborators = tracker .collaborators() .map(|c| CollaboratorInfo { - node_id: c.node_id.to_string(), + node_id: c.node_id.to_smolstr(), did: c.did.clone(), display_name: c.display_name.clone(), color: c.color, @@ -689,11 +803,11 @@ mod embed_worker_impl { use super::*; use crate::cache_impl; use gloo_worker::{HandlerId, Worker, WorkerScope}; + use jacquard::IntoStatic; use jacquard::client::UnauthenticatedSession; use jacquard::identity::JacquardResolver; use jacquard::prelude::*; use jacquard::types::string::AtUri; - use jacquard::IntoStatic; use std::time::Duration; /// Embed worker with persistent cache. @@ -731,7 +845,7 @@ mod embed_worker_impl { let at_uri = match AtUri::new_owned(uri_str.clone()) { Ok(u) => u, Err(e) => { - errors.insert(uri_str, format_smolstr!("Invalid AT URI: {e}").to_string()); + errors.insert(uri_str, format!("Invalid AT URI: {e}")); continue; } }; @@ -773,7 +887,7 @@ mod embed_worker_impl { results.insert(uri_str, html); } Err(e) => { - errors.insert(uri_str, format_smolstr!("{:?}", e).to_string()); + errors.insert(uri_str, format!("{:?}", e)); } } } diff --git a/crates/weaver-app/src/components/entry.rs b/crates/weaver-app/src/components/entry.rs index 4067652..2951388 100644 --- a/crates/weaver-app/src/components/entry.rs +++ b/crates/weaver-app/src/components/entry.rs @@ -23,17 +23,6 @@ use jacquard::{ use std::sync::Arc; use weaver_api::sh_weaver::notebook::{BookEntryView, EntryView, entry}; -// #[component] -// pub fn EntryPage( -// ident: ReadSignal>, -// book_title: ReadSignal, -// title: ReadSignal, -// ) -> Element { -// rsx! { -// {std::iter::once(rsx! {Entry {ident, book_title, title}})} -// } -// } - #[component] pub fn EntryPage( ident: ReadSignal>, @@ -81,31 +70,6 @@ pub fn EntryPage( // Use read() instead of read_unchecked() for proper reactive tracking match &*entry.read() { Some((book_entry_view, entry_record)) => { - if let Some(embeds) = &entry_record.embeds { - if let Some(_images) = &embeds.images { - // Register blob mappings with service worker (client-side only) - // #[cfg(all( - // target_family = "wasm", - // target_os = "unknown", - // not(feature = "fullstack-server") - // ))] - // { - // let fetcher = fetcher.clone(); - // let images = _images.clone().into_static(); - // spawn(async move { - // let images = images.clone(); - // let fetcher = fetcher.clone(); - // let _ = crate::service_worker::register_entry_blobs( - // &ident(), - // book_title().as_str(), - // &_images, - // &fetcher, - // ) - // .await; - // }); - // } - } - } rsx! { EntryPageView { book_entry_view: book_entry_view.clone(), entry_record: entry_record.clone(), @@ -151,61 +115,6 @@ pub fn extract_preview(content: &str, max_len: usize) -> String { } } -/// Truncate markdown content for preview (preserves markdown syntax) -/// Takes first few paragraphs up to max_chars, truncating at paragraph boundary -fn truncate_markdown_preview(content: &str, max_chars: usize, max_paragraphs: usize) -> String { - let mut result = String::new(); - let mut char_count = 0; - let mut para_count = 0; - let mut in_code_block = false; - - for line in content.lines() { - // Track code blocks to avoid breaking them - if line.trim().starts_with("```") { - in_code_block = !in_code_block; - // Skip code blocks in preview entirely - if in_code_block { - continue; - } - } - - if in_code_block { - continue; - } - - // Skip headings, images in preview - let trimmed = line.trim(); - if trimmed.starts_with('#') || trimmed.starts_with('!') { - continue; - } - - // Empty line = paragraph boundary - if trimmed.is_empty() { - if !result.is_empty() && !result.ends_with("\n\n") { - para_count += 1; - if para_count >= max_paragraphs || char_count >= max_chars { - break; - } - result.push_str("\n\n"); - } - continue; - } - - // Check if adding this line would exceed limit - if char_count + line.len() > max_chars && !result.is_empty() { - break; - } - - if !result.is_empty() && !result.ends_with('\n') { - result.push('\n'); - } - result.push_str(line); - char_count += line.len(); - } - - result.trim().to_string() -} - /// OpenGraph and Twitter Card meta tags for entries #[component] pub fn EntryOgMeta( diff --git a/crates/weaver-app/src/components/identity.rs b/crates/weaver-app/src/components/identity.rs index 490e8e9..446d03c 100644 --- a/crates/weaver-app/src/components/identity.rs +++ b/crates/weaver-app/src/components/identity.rs @@ -107,22 +107,26 @@ pub fn RepositoryIndex(ident: ReadSignal>) -> Element { }); // Extract pinned URIs from profile (only Weaver ProfileView has pinned) + // Returns (Vec for ordering, HashSet for O(1) lookups) let pinned_uris = use_memo(move || { use jacquard::IntoStatic; - use jacquard::types::aturi::AtUri; use weaver_api::sh_weaver::actor::ProfileDataViewInner; let Some(prof) = profile.read().as_ref().cloned() else { - return Vec::>::new(); + return (Vec::::new(), HashSet::::new()); }; match &prof.inner { - ProfileDataViewInner::ProfileView(p) => p - .pinned - .as_ref() - .map(|pins| pins.iter().map(|r| r.uri.clone().into_static()).collect()) - .unwrap_or_default(), - _ => Vec::new(), + ProfileDataViewInner::ProfileView(p) => { + let uris: Vec = p + .pinned + .as_ref() + .map(|pins| pins.iter().map(|r| r.uri.as_ref().to_string()).collect()) + .unwrap_or_default(); + let set: HashSet = uris.iter().cloned().collect(); + (uris, set) + } + _ => (Vec::new(), HashSet::new()), } }); @@ -149,8 +153,8 @@ pub fn RepositoryIndex(ident: ReadSignal>) -> Element { }); // Helper to check if a URI is pinned - fn is_pinned(uri: &str, pinned: &[jacquard::types::aturi::AtUri<'static>]) -> bool { - pinned.iter().any(|p| p.as_ref() == uri) + fn is_pinned(uri: &str, pinned_set: &HashSet) -> bool { + pinned_set.contains(uri) } // Build pinned items (matching notebooks/entries against pinned URIs) @@ -158,7 +162,7 @@ pub fn RepositoryIndex(ident: ReadSignal>) -> Element { let nbs = notebooks.read(); let standalone = standalone_entries.read(); let ents = all_entries.read(); - let pinned = pinned_uris.read(); + let (pinned_vec, pinned_set) = &*pinned_uris.read(); let mut items: Vec = Vec::new(); @@ -166,7 +170,7 @@ pub fn RepositoryIndex(ident: ReadSignal>) -> Element { if let Some(nbs) = nbs.as_ref() { if let Some(all_ents) = ents.as_ref() { for (notebook, entry_refs) in nbs { - if is_pinned(notebook.uri.as_ref(), &pinned) { + if is_pinned(notebook.uri.as_ref(), pinned_set) { let sort_date = entry_refs .iter() .filter_map(|r| { @@ -190,7 +194,7 @@ pub fn RepositoryIndex(ident: ReadSignal>) -> Element { // Check standalone entries for (view, entry) in standalone.iter() { - if is_pinned(view.uri.as_ref(), &pinned) { + if is_pinned(view.uri.as_ref(), pinned_set) { items.push(ProfileTimelineItem::StandaloneEntry { entry_view: view.clone(), entry: entry.clone(), @@ -204,9 +208,9 @@ pub fn RepositoryIndex(ident: ReadSignal>) -> Element { ProfileTimelineItem::Notebook { notebook, .. } => notebook.uri.as_ref(), ProfileTimelineItem::StandaloneEntry { entry_view, .. } => entry_view.uri.as_ref(), }; - pinned + pinned_vec .iter() - .position(|p| p.as_ref() == uri) + .position(|p| p == uri) .unwrap_or(usize::MAX) }); @@ -218,7 +222,7 @@ pub fn RepositoryIndex(ident: ReadSignal>) -> Element { let nbs = notebooks.read(); let standalone = standalone_entries.read(); let ents = all_entries.read(); - let pinned = pinned_uris.read(); + let (_pinned_vec, pinned_set) = &*pinned_uris.read(); let mut items: Vec = Vec::new(); @@ -226,7 +230,7 @@ pub fn RepositoryIndex(ident: ReadSignal>) -> Element { if let Some(nbs) = nbs.as_ref() { if let Some(all_ents) = ents.as_ref() { for (notebook, entry_refs) in nbs { - if !is_pinned(notebook.uri.as_ref(), &pinned) { + if !is_pinned(notebook.uri.as_ref(), pinned_set) { let sort_date = entry_refs .iter() .filter_map(|r| { @@ -250,7 +254,7 @@ pub fn RepositoryIndex(ident: ReadSignal>) -> Element { // Add standalone entries (excluding pinned) for (view, entry) in standalone.iter() { - if !is_pinned(view.uri.as_ref(), &pinned) { + if !is_pinned(view.uri.as_ref(), pinned_set) { items.push(ProfileTimelineItem::StandaloneEntry { entry_view: view.clone(), entry: entry.clone(), @@ -442,6 +446,86 @@ pub fn RepositoryIndex(ident: ReadSignal>) -> Element { } } +#[component] +fn NotebookEntryPreview( + book_entry_view: weaver_api::sh_weaver::notebook::BookEntryView<'static>, + ident: AtIdentifier<'static>, + book_title: SmolStr, + #[props(default)] extra_class: Option<&'static str>, +) -> Element { + use jacquard::{IntoStatic, from_data}; + use weaver_api::sh_weaver::notebook::entry::Entry; + + let entry_view = &book_entry_view.entry; + + let entry_title = entry_view.title.as_ref() + .map(|t| t.as_ref()) + .unwrap_or("Untitled"); + + let entry_path = entry_view.path + .as_ref() + .map(|p| p.as_ref().to_string()) + .unwrap_or_else(|| entry_title.to_string()); + + let parsed_entry = from_data::(&entry_view.record).ok(); + + let preview_html = parsed_entry.as_ref().map(|entry| { + let parser = markdown_weaver::Parser::new(&entry.content); + let mut html_buf = String::new(); + markdown_weaver::html::push_html(&mut html_buf, parser); + html_buf + }); + + let created_at = parsed_entry.as_ref() + .map(|entry| entry.created_at.as_ref().format("%B %d, %Y").to_string()); + + let entry_uri = entry_view.uri.clone().into_static(); + + let class_name = if let Some(extra) = extra_class { + format!("notebook-entry-preview {}", extra) + } else { + "notebook-entry-preview".to_string() + }; + + rsx! { + div { class: "{class_name}", + div { class: "entry-preview-header", + Link { + to: Route::EntryPage { + ident: ident.clone(), + book_title: book_title.clone(), + title: entry_path.clone().into() + }, + class: "entry-preview-title-link", + div { class: "entry-preview-title", "{entry_title}" } + } + if let Some(ref date) = created_at { + div { class: "entry-preview-date", "{date}" } + } + crate::components::EntryActions { + entry_uri, + entry_cid: entry_view.cid.clone().into_static(), + entry_title: entry_title.to_string(), + in_notebook: true, + notebook_title: Some(book_title.clone()), + permissions: entry_view.permissions.clone() + } + } + if let Some(ref html) = preview_html { + Link { + to: Route::EntryPage { + ident: ident.clone(), + book_title: book_title.clone(), + title: entry_path.clone().into() + }, + class: "entry-preview-content-link", + div { class: "entry-preview-content", dangerous_inner_html: "{html}" } + } + } + } + } +} + #[component] pub fn NotebookCard( notebook: NotebookView<'static>, @@ -567,71 +651,11 @@ pub fn NotebookCard( if entry_list.len() <= 5 { // Show all entries if 5 or fewer rsx! { - for entry_view in entry_list.iter() { - { - let entry_title = entry_view.entry.title.as_ref() - .map(|t| t.as_ref()) - .unwrap_or("Untitled"); - - // Get path from view, fallback to title - let entry_path = entry_view.entry.path - .as_ref() - .map(|p| p.as_ref().to_string()) - .unwrap_or_else(|| entry_title.to_string()); - - // Parse entry for created_at and preview - let parsed_entry = from_data::(&entry_view.entry.record).ok(); - - let preview_html = parsed_entry.as_ref().map(|entry| { - let parser = markdown_weaver::Parser::new(&entry.content); - let mut html_buf = String::new(); - markdown_weaver::html::push_html(&mut html_buf, parser); - html_buf - }); - - let created_at = parsed_entry.as_ref() - .map(|entry| entry.created_at.as_ref().format("%B %d, %Y").to_string()); - - let entry_uri = entry_view.entry.uri.clone().into_static(); - - rsx! { - div { class: "notebook-entry-preview", - div { class: "entry-preview-header", - Link { - to: Route::EntryPage { - ident: ident.clone(), - book_title: book_title.clone(), - title: entry_path.clone().into() - }, - class: "entry-preview-title-link", - div { class: "entry-preview-title", "{entry_title}" } - } - if let Some(ref date) = created_at { - div { class: "entry-preview-date", "{date}" } - } - // EntryActions handles visibility via permissions - crate::components::EntryActions { - entry_uri, - entry_cid: entry_view.entry.cid.clone().into_static(), - entry_title: entry_title.to_string(), - in_notebook: true, - notebook_title: Some(book_title.clone()), - permissions: entry_view.entry.permissions.clone() - } - } - if let Some(ref html) = preview_html { - Link { - to: Route::EntryPage { - ident: ident.clone(), - book_title: book_title.clone(), - title: entry_path.clone().into() - }, - class: "entry-preview-content-link", - div { class: "entry-preview-content", dangerous_inner_html: "{html}" } - } - } - } - } + for entry_view in entry_list.iter() { + NotebookEntryPreview { + book_entry_view: entry_view.clone(), + ident: ident.clone(), + book_title: book_title.clone(), } } } @@ -639,70 +663,11 @@ pub fn NotebookCard( // Show first, interstitial, and last rsx! { if let Some(first_entry) = entry_list.first() { - { - let entry_title = first_entry.entry.title.as_ref() - .map(|t| t.as_ref()) - .unwrap_or("Untitled"); - - // Get path from view, fallback to title - let entry_path = first_entry.entry.path - .as_ref() - .map(|p| p.as_ref().to_string()) - .unwrap_or_else(|| entry_title.to_string()); - - // Parse entry for created_at and preview - let parsed_entry = from_data::(&first_entry.entry.record).ok(); - - let preview_html = parsed_entry.as_ref().map(|entry| { - let parser = markdown_weaver::Parser::new(&entry.content); - let mut html_buf = String::new(); - markdown_weaver::html::push_html(&mut html_buf, parser); - html_buf - }); - - let created_at = parsed_entry.as_ref() - .map(|entry| entry.created_at.as_ref().format("%B %d, %Y").to_string()); - - let entry_uri = first_entry.entry.uri.clone().into_static(); - - rsx! { - div { class: "notebook-entry-preview notebook-entry-preview-first", - div { class: "entry-preview-header", - Link { - to: Route::EntryPage { - ident: ident.clone(), - book_title: book_title.clone(), - title: entry_path.clone().into() - }, - class: "entry-preview-title-link", - div { class: "entry-preview-title", "{entry_title}" } - } - if let Some(ref date) = created_at { - div { class: "entry-preview-date", "{date}" } - } - // EntryActions handles visibility via permissions - crate::components::EntryActions { - entry_uri, - entry_cid: first_entry.entry.cid.clone().into_static(), - entry_title: entry_title.to_string(), - in_notebook: true, - notebook_title: Some(book_title.clone()), - permissions: first_entry.entry.permissions.clone() - } - } - if let Some(ref html) = preview_html { - Link { - to: Route::EntryPage { - ident: ident.clone(), - book_title: book_title.clone(), - title: entry_path.clone().into() - }, - class: "entry-preview-content-link", - div { class: "entry-preview-content", dangerous_inner_html: "{html}" } - } - } - } - } + NotebookEntryPreview { + book_entry_view: first_entry.clone(), + ident: ident.clone(), + book_title: book_title.clone(), + extra_class: "notebook-entry-preview-first", } } @@ -719,70 +684,11 @@ pub fn NotebookCard( } if let Some(last_entry) = entry_list.last() { - { - let entry_title = last_entry.entry.title.as_ref() - .map(|t| t.as_ref()) - .unwrap_or("Untitled"); - - // Get path from view, fallback to title - let entry_path = last_entry.entry.path - .as_ref() - .map(|p| p.as_ref().to_string()) - .unwrap_or_else(|| entry_title.to_string()); - - // Parse entry for created_at and preview - let parsed_entry = from_data::(&last_entry.entry.record).ok(); - - let preview_html = parsed_entry.as_ref().map(|entry| { - let parser = markdown_weaver::Parser::new(&entry.content); - let mut html_buf = String::new(); - markdown_weaver::html::push_html(&mut html_buf, parser); - html_buf - }); - - let created_at = parsed_entry.as_ref() - .map(|entry| entry.created_at.as_ref().format("%B %d, %Y").to_string()); - - let entry_uri = last_entry.entry.uri.clone().into_static(); - - rsx! { - div { class: "notebook-entry-preview notebook-entry-preview-last", - div { class: "entry-preview-header", - Link { - to: Route::EntryPage { - ident: ident.clone(), - book_title: book_title.clone(), - title: entry_path.clone().into() - }, - class: "entry-preview-title-link", - div { class: "entry-preview-title", "{entry_title}" } - } - if let Some(ref date) = created_at { - div { class: "entry-preview-date", "{date}" } - } - // EntryActions handles visibility via permissions - crate::components::EntryActions { - entry_uri, - entry_cid: last_entry.entry.cid.clone().into_static(), - entry_title: entry_title.to_string(), - in_notebook: true, - notebook_title: Some(book_title.clone()), - permissions: last_entry.entry.permissions.clone() - } - } - if let Some(ref html) = preview_html { - Link { - to: Route::EntryPage { - ident: ident.clone(), - book_title: book_title.clone(), - title: entry_path.clone().into() - }, - class: "entry-preview-content-link", - div { class: "entry-preview-content", dangerous_inner_html: "{html}" } - } - } - } - } + NotebookEntryPreview { + book_entry_view: last_entry.clone(), + ident: ident.clone(), + book_title: book_title.clone(), + extra_class: "notebook-entry-preview-last", } } } diff --git a/crates/weaver-app/src/data.rs b/crates/weaver-app/src/data.rs index bc9bb70..650b6b8 100644 --- a/crates/weaver-app/src/data.rs +++ b/crates/weaver-app/src/data.rs @@ -906,18 +906,6 @@ pub fn use_entries_from_ufos() -> ( .await .ok(); } - #[cfg(all(target_family = "wasm", target_os = "unknown",))] - { - tracing::info!("Registering standalone entry blobs"); - let _ = - crate::service_worker::register_standalone_entry_blobs( - &ident, - at_uri.rkey().unwrap().0.as_str(), - images, - &fetcher, - ) - .await; - } } } } diff --git a/crates/weaver-app/src/og/mod.rs b/crates/weaver-app/src/og/mod.rs index e081e97..854832d 100644 --- a/crates/weaver-app/src/og/mod.rs +++ b/crates/weaver-app/src/og/mod.rs @@ -5,8 +5,7 @@ pub mod server; use crate::cache_impl::{Cache, new_cache}; use askama::Template; -use jacquard::smol_str::SmolStr; -use jacquard::smol_str::format_smolstr; +use jacquard::smol_str::{SmolStr, ToSmolStr, format_smolstr}; use std::sync::OnceLock; use std::time::Duration; @@ -40,9 +39,9 @@ pub fn cache_image(key: SmolStr, image: Vec) { #[derive(Debug)] pub enum OgError { NotFound, - FetchError(String), - RenderError(String), - TemplateError(String), + FetchError(SmolStr), + RenderError(SmolStr), + TemplateError(SmolStr), } impl std::fmt::Display for OgError { @@ -77,8 +76,8 @@ mod colors { pub struct TextOnlyTemplate { pub title_lines: Vec, pub content_lines: Vec, - pub notebook_title: String, - pub author_handle: String, + pub notebook_title: SmolStr, + pub author_handle: SmolStr, } /// Hero image template (full-bleed image with overlay) @@ -87,8 +86,8 @@ pub struct TextOnlyTemplate { pub struct HeroImageTemplate { pub hero_image_data: String, pub title_lines: Vec, - pub notebook_title: String, - pub author_handle: String, + pub notebook_title: SmolStr, + pub author_handle: SmolStr, } /// Notebook index template @@ -96,7 +95,7 @@ pub struct HeroImageTemplate { #[template(path = "og_notebook.svg", escape = "none")] pub struct NotebookTemplate { pub title_lines: Vec, - pub author_handle: String, + pub author_handle: SmolStr, pub entry_count: usize, pub entry_titles: Vec, } @@ -107,7 +106,7 @@ pub struct NotebookTemplate { pub struct ProfileTemplate { pub avatar_data: Option, pub display_name_lines: Vec, - pub handle: String, + pub handle: SmolStr, pub bio_lines: Vec, pub notebook_count: usize, } @@ -119,7 +118,7 @@ pub struct ProfileBannerTemplate { pub banner_image_data: String, pub avatar_data: Option, pub display_name_lines: Vec, - pub handle: String, + pub handle: SmolStr, pub bio_lines: Vec, pub notebook_count: usize, } @@ -166,16 +165,16 @@ pub fn render_svg_to_png(svg: &str) -> Result, OgError> { }; let tree = usvg::Tree::from_str(svg, &options) - .map_err(|e| OgError::RenderError(format!("Failed to parse SVG: {}", e)))?; + .map_err(|e| OgError::RenderError(format_smolstr!("Failed to parse SVG: {}", e)))?; let mut pixmap = tiny_skia::Pixmap::new(OG_WIDTH, OG_HEIGHT) - .ok_or_else(|| OgError::RenderError("Failed to create pixmap".to_string()))?; + .ok_or_else(|| OgError::RenderError("Failed to create pixmap".to_smolstr()))?; resvg::render(&tree, tiny_skia::Transform::default(), &mut pixmap.as_mut()); pixmap .encode_png() - .map_err(|e| OgError::RenderError(format!("Failed to encode PNG: {}", e))) + .map_err(|e| OgError::RenderError(format_smolstr!("Failed to encode PNG: {}", e))) } /// Generate a text-only OG image @@ -191,13 +190,13 @@ pub fn generate_text_only( let template = TextOnlyTemplate { title_lines, content_lines, - notebook_title: notebook_title.to_string(), - author_handle: author_handle.to_string(), + notebook_title: notebook_title.to_smolstr(), + author_handle: author_handle.to_smolstr(), }; let svg = template .render() - .map_err(|e| OgError::TemplateError(e.to_string()))?; + .map_err(|e| OgError::TemplateError(e.to_smolstr()))?; render_svg_to_png(&svg) } @@ -214,13 +213,13 @@ pub fn generate_hero_image( let template = HeroImageTemplate { hero_image_data: hero_image_data.to_string(), title_lines, - notebook_title: notebook_title.to_string(), - author_handle: author_handle.to_string(), + notebook_title: notebook_title.to_smolstr(), + author_handle: author_handle.to_smolstr(), }; let svg = template .render() - .map_err(|e| OgError::TemplateError(e.to_string()))?; + .map_err(|e| OgError::TemplateError(e.to_smolstr()))?; render_svg_to_png(&svg) } @@ -258,14 +257,14 @@ pub fn generate_notebook_og( let template = NotebookTemplate { title_lines, - author_handle: author_handle.to_string(), + author_handle: author_handle.to_smolstr(), entry_count, entry_titles, }; let svg = template .render() - .map_err(|e| OgError::TemplateError(e.to_string()))?; + .map_err(|e| OgError::TemplateError(e.to_smolstr()))?; render_svg_to_png(&svg) } @@ -284,14 +283,14 @@ pub fn generate_profile_og( let template = ProfileTemplate { avatar_data, display_name_lines, - handle: handle.to_string(), + handle: handle.to_smolstr(), bio_lines, notebook_count, }; let svg = template .render() - .map_err(|e| OgError::TemplateError(e.to_string()))?; + .map_err(|e| OgError::TemplateError(e.to_smolstr()))?; render_svg_to_png(&svg) } @@ -312,14 +311,14 @@ pub fn generate_profile_banner_og( banner_image_data, avatar_data, display_name_lines, - handle: handle.to_string(), + handle: handle.to_smolstr(), bio_lines, notebook_count, }; let svg = template .render() - .map_err(|e| OgError::TemplateError(e.to_string()))?; + .map_err(|e| OgError::TemplateError(e.to_smolstr()))?; render_svg_to_png(&svg) } @@ -330,7 +329,7 @@ pub fn generate_site_og() -> Result, OgError> { let svg = template .render() - .map_err(|e| OgError::TemplateError(e.to_string()))?; + .map_err(|e| OgError::TemplateError(e.to_smolstr()))?; render_svg_to_png(&svg) } diff --git a/crates/weaver-common/src/agent.rs b/crates/weaver-common/src/agent.rs index 9d24664..59d14da 100644 --- a/crates/weaver-common/src/agent.rs +++ b/crates/weaver-common/src/agent.rs @@ -16,6 +16,7 @@ use jacquard::types::blob::{BlobRef, MimeType}; use jacquard::types::string::{AtUri, Did, RecordKey, Rkey}; use jacquard::types::tid::Tid; use jacquard::types::uri::Uri; +use jacquard::smol_str::SmolStr; use jacquard::url::Url; use jacquard::{CowStr, IntoStatic}; use mime_sniffer::MimeTypeSniffer; @@ -2180,11 +2181,11 @@ pub trait WeaverExt: AgentSessionExt + XrpcExt + Send + Sync + Sized { peers.push(SessionPeer { did: record_id.did.into_static(), - node_id: session_record.value.node_id.to_string(), + node_id: session_record.value.node_id.as_ref().into(), relay_url: session_record .value .relay_url - .map(|u| u.as_str().to_string()), + .map(|u| u.as_ref().into()), created_at: session_record.value.created_at, expires_at: session_record.value.expires_at, }); @@ -2301,9 +2302,9 @@ pub struct SessionPeer<'a> { /// The peer's DID. pub did: Did<'a>, /// The peer's iroh NodeId (z-base32 encoded). - pub node_id: String, + pub node_id: SmolStr, /// Optional relay URL for browser clients. - pub relay_url: Option, + pub relay_url: Option, /// When the session was created. pub created_at: jacquard::types::string::Datetime, /// When the session expires (if set). diff --git a/crates/weaver-common/src/transport/messages.rs b/crates/weaver-common/src/transport/messages.rs index d3a0395..30a06a6 100644 --- a/crates/weaver-common/src/transport/messages.rs +++ b/crates/weaver-common/src/transport/messages.rs @@ -1,5 +1,6 @@ //! Wire protocol for collaborative editing messages. +use jacquard::smol_str::SmolStr; use serde::{Deserialize, Serialize}; /// Messages exchanged between collaborators over gossip. @@ -26,15 +27,15 @@ pub enum CollabMessage { /// Collaborator joined the session Join { /// DID of the joining user - did: String, + did: SmolStr, /// Display name for presence UI - display_name: String, + display_name: SmolStr, }, /// Collaborator left the session Leave { /// DID of the leaving user - did: String, + did: SmolStr, }, /// Request sync from peers (late joiner) @@ -183,8 +184,8 @@ mod tests { #[test] fn test_roundtrip_join() { let msg = CollabMessage::Join { - did: "did:plc:abc123".to_string(), - display_name: "Alice".to_string(), + did: "did:plc:abc123".into(), + display_name: "Alice".into(), }; let bytes = msg.to_bytes().unwrap(); let decoded = CollabMessage::from_bytes(&bytes).unwrap(); diff --git a/crates/weaver-common/src/transport/node.rs b/crates/weaver-common/src/transport/node.rs index 1809a1d..eec3d4f 100644 --- a/crates/weaver-common/src/transport/node.rs +++ b/crates/weaver-common/src/transport/node.rs @@ -6,6 +6,7 @@ use iroh::Endpoint; use iroh::EndpointId; use iroh::SecretKey; use iroh_gossip::net::{GOSSIP_ALPN, Gossip}; +use jacquard::smol_str::{SmolStr, ToSmolStr}; use miette::Diagnostic; use std::sync::Arc; @@ -80,8 +81,8 @@ impl CollabNode { } /// Get the node ID as a z-base32 string for storage in AT Protocol records. - pub fn node_id_string(&self) -> String { - self.endpoint.id().to_string() + pub fn node_id_string(&self) -> SmolStr { + self.endpoint.id().to_smolstr() } /// Get a reference to the gossip handler for joining topics. @@ -103,12 +104,12 @@ impl CollabNode { /// /// This should be published in session records so other peers can connect /// via relay (essential for browser-to-browser connections). - pub fn relay_url(&self) -> Option { + pub fn relay_url(&self) -> Option { self.endpoint .addr() .relay_urls() .next() - .map(|url| url.to_string()) + .map(|url| url.to_smolstr()) } /// Get the full node address including relay info. @@ -131,7 +132,7 @@ impl CollabNode { /// /// Waits indefinitely for relay - browser clients require relay URLs /// for peer discovery. Returns the relay URL once connected. - pub async fn wait_for_relay(&self) -> String { + pub async fn wait_for_relay(&self) -> SmolStr { self.endpoint.online().await; // After online(), relay_url should always be Some for browser clients self.relay_url() diff --git a/crates/weaver-common/src/transport/presence.rs b/crates/weaver-common/src/transport/presence.rs index 9bd6b69..843c2a5 100644 --- a/crates/weaver-common/src/transport/presence.rs +++ b/crates/weaver-common/src/transport/presence.rs @@ -7,6 +7,7 @@ use std::collections::HashMap; use iroh::EndpointId; +use jacquard::smol_str::SmolStr; use web_time::Instant; /// A remote collaborator's cursor state. @@ -26,9 +27,9 @@ pub struct RemoteCursor { #[derive(Debug, Clone)] pub struct Collaborator { /// The collaborator's DID. - pub did: String, + pub did: SmolStr, /// Display name for UI. - pub display_name: String, + pub display_name: SmolStr, /// Assigned colour (RGBA). pub color: u32, /// Current cursor state. @@ -65,13 +66,18 @@ impl PresenceTracker { } /// Add a collaborator when they join. - pub fn add_collaborator(&mut self, node_id: EndpointId, did: String, display_name: String) { + pub fn add_collaborator( + &mut self, + node_id: EndpointId, + did: impl Into, + display_name: impl Into, + ) { let color = self.assign_color(); self.collaborators.insert( node_id, Collaborator { - did, - display_name, + did: did.into(), + display_name: display_name.into(), color, cursor: None, node_id, diff --git a/crates/weaver-common/src/transport/presence_types.rs b/crates/weaver-common/src/transport/presence_types.rs index ad6242a..0af30f6 100644 --- a/crates/weaver-common/src/transport/presence_types.rs +++ b/crates/weaver-common/src/transport/presence_types.rs @@ -1,15 +1,16 @@ //! Presence types for main thread rendering. //! -//! These types use String node IDs instead of EndpointId, +//! These types use SmolStr node IDs instead of EndpointId, //! allowing them to be used without the iroh feature. +use jacquard::smol_str::SmolStr; use serde::{Deserialize, Serialize}; /// A remote collaborator's cursor for rendering. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct RemoteCursorInfo { /// Node ID as string (z-base32 encoded) - pub node_id: String, + pub node_id: SmolStr, /// Character offset in the document pub position: usize, /// Selection range (anchor, head) if any @@ -22,11 +23,11 @@ pub struct RemoteCursorInfo { #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct CollaboratorInfo { /// Node ID as string - pub node_id: String, + pub node_id: SmolStr, /// The collaborator's DID - pub did: String, + pub did: SmolStr, /// Display name for UI - pub display_name: String, + pub display_name: SmolStr, /// Assigned colour (RGBA) pub color: u32, /// Current cursor position (if known) -- knotmirror 0.0.1