collab working in worker

Orual 3c83bb11 b95932ff

+658 -775
+12 -12
crates/weaver-app/public/editor_worker.js
··· 232 232 233 233 let WASM_VECTOR_LEN = 0; 234 234 235 - function wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke_______3_(arg0, arg1) { 236 - wasm.wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke_______3_(arg0, arg1); 235 + function wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke___wasm_bindgen_1add006a0ed82fd3___JsValue_____(arg0, arg1, arg2) { 236 + wasm.wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke___wasm_bindgen_1add006a0ed82fd3___JsValue_____(arg0, arg1, arg2); 237 237 } 238 238 239 - function wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke___web_sys_1984c39bba2ffe3a___features__gen_MessageEvent__MessageEvent_____(arg0, arg1, arg2) { 240 - wasm.wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke___web_sys_1984c39bba2ffe3a___features__gen_MessageEvent__MessageEvent_____(arg0, arg1, arg2); 239 + function wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke_______3_(arg0, arg1) { 240 + wasm.wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke_______3_(arg0, arg1); 241 241 } 242 242 243 - function wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke___wasm_bindgen_1add006a0ed82fd3___JsValue_____(arg0, arg1, arg2) { 244 - wasm.wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke___wasm_bindgen_1add006a0ed82fd3___JsValue_____(arg0, arg1, arg2); 243 + function wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke_______1_(arg0, arg1) { 244 + wasm.wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke_______1_(arg0, arg1); 245 245 } 246 246 247 247 function wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke______(arg0, arg1) { 248 248 wasm.wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke______(arg0, arg1); 249 + } 250 + 251 + function wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke___web_sys_1984c39bba2ffe3a___features__gen_MessageEvent__MessageEvent_____(arg0, arg1, arg2) { 252 + wasm.wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke___web_sys_1984c39bba2ffe3a___features__gen_MessageEvent__MessageEvent_____(arg0, arg1, arg2); 249 253 } 250 254 251 255 function wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke___web_sys_1984c39bba2ffe3a___features__gen_MessageEvent__MessageEvent______1_(arg0, arg1, arg2) { ··· 258 262 259 263 function wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke___web_sys_1984c39bba2ffe3a___features__gen_CloseEvent__CloseEvent_____(arg0, arg1, arg2) { 260 264 wasm.wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke___web_sys_1984c39bba2ffe3a___features__gen_CloseEvent__CloseEvent_____(arg0, arg1, arg2); 261 - } 262 - 263 - function wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke_______1_(arg0, arg1) { 264 - wasm.wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke_______1_(arg0, arg1); 265 265 } 266 266 267 267 function wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke___wasm_bindgen_1add006a0ed82fd3___JsValue__wasm_bindgen_1add006a0ed82fd3___JsValue_____(arg0, arg1, arg2, arg3) { ··· 1071 1071 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_); 1072 1072 return ret; 1073 1073 }; 1074 - imports.wbg.__wbindgen_cast_70089f6cfcc0d4b7 = function(arg0, arg1) { 1075 - // 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`. 1074 + imports.wbg.__wbindgen_cast_82f5386d361eee3f = function(arg0, arg1) { 1075 + // 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`. 1076 1076 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_____); 1077 1077 return ret; 1078 1078 };
+15 -15
crates/weaver-app/public/embed_worker.js
··· 232 232 233 233 let WASM_VECTOR_LEN = 0; 234 234 235 - function wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke_______1_(arg0, arg1) { 236 - wasm.wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke_______1_(arg0, arg1); 237 - } 238 - 239 235 function wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke___wasm_bindgen_1add006a0ed82fd3___JsValue_____(arg0, arg1, arg2) { 240 236 wasm.wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke___wasm_bindgen_1add006a0ed82fd3___JsValue_____(arg0, arg1, arg2); 237 + } 238 + 239 + function wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke_______1_(arg0, arg1) { 240 + wasm.wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke_______1_(arg0, arg1); 241 241 } 242 242 243 243 function wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke___web_sys_1984c39bba2ffe3a___features__gen_MessageEvent__MessageEvent_____(arg0, arg1, arg2) { ··· 809 809 const ret = getStringFromWasm0(arg0, arg1); 810 810 return ret; 811 811 }; 812 - imports.wbg.__wbindgen_cast_28fcab414066dd47 = function(arg0, arg1) { 813 - // Cast intrinsic for `Closure(Closure { dtor_idx: 2276, function: Function { arguments: [Externref], shim_idx: 2277, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. 814 - 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_____); 812 + imports.wbg.__wbindgen_cast_b44cc294b43af732 = function(arg0, arg1) { 813 + // Cast intrinsic for `Closure(Closure { dtor_idx: 1192, function: Function { arguments: [], shim_idx: 1193, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. 814 + const ret = makeMutClosure(arg0, arg1, wasm.wasm_bindgen_1add006a0ed82fd3___closure__destroy___dyn_core_b125d98f3949a913___ops__function__FnMut_____Output_______, wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke______); 815 815 return ret; 816 816 }; 817 - imports.wbg.__wbindgen_cast_80e60da953ba3dd9 = function(arg0, arg1) { 818 - // 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`. 817 + imports.wbg.__wbindgen_cast_c8f3d1634b3b2a31 = function(arg0, arg1) { 818 + // 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`. 819 819 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_____); 820 820 return ret; 821 821 }; 822 - imports.wbg.__wbindgen_cast_a731521d4dc80277 = function(arg0, arg1) { 823 - // Cast intrinsic for `Closure(Closure { dtor_idx: 1194, function: Function { arguments: [], shim_idx: 1195, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. 824 - const ret = makeMutClosure(arg0, arg1, wasm.wasm_bindgen_1add006a0ed82fd3___closure__destroy___dyn_core_b125d98f3949a913___ops__function__FnMut_____Output_______, wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke______); 822 + imports.wbg.__wbindgen_cast_e60e2a067b8a256d = function(arg0, arg1) { 823 + // Cast intrinsic for `Closure(Closure { dtor_idx: 1452, function: Function { arguments: [], shim_idx: 1453, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. 824 + 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_); 825 825 return ret; 826 826 }; 827 - imports.wbg.__wbindgen_cast_e0273882c29884ec = function(arg0, arg1) { 828 - // Cast intrinsic for `Closure(Closure { dtor_idx: 1454, function: Function { arguments: [], shim_idx: 1455, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. 829 - 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_); 827 + imports.wbg.__wbindgen_cast_e76354026ca30a8f = function(arg0, arg1) { 828 + // Cast intrinsic for `Closure(Closure { dtor_idx: 2274, function: Function { arguments: [Externref], shim_idx: 2275, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. 829 + 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_____); 830 830 return ret; 831 831 }; 832 832 imports.wbg.__wbindgen_init_externref_table = function() {
+4 -3
crates/weaver-app/src/collab_context.rs
··· 4 4 //! the CollabCoordinator component for display in the editor debug panel. 5 5 6 6 use dioxus::prelude::*; 7 + use jacquard::smol_str::SmolStr; 7 8 8 9 /// Debug state for the collab session, displayed in editor debug panel. 9 10 #[derive(Clone, Default)] 10 11 pub struct CollabDebugState { 11 12 /// Our node ID 12 - pub node_id: Option<String>, 13 + pub node_id: Option<SmolStr>, 13 14 /// Our relay URL 14 - pub relay_url: Option<String>, 15 + pub relay_url: Option<SmolStr>, 15 16 /// URI of our published session record 16 17 pub session_record_uri: Option<String>, 17 18 /// Number of discovered peers ··· 21 22 /// Whether we've joined the gossip swarm 22 23 pub is_joined: bool, 23 24 /// Last error message 24 - pub last_error: Option<String>, 25 + pub last_error: Option<SmolStr>, 25 26 } 26 27 27 28 /// Hook to get the collab debug state signal.
-6
crates/weaver-app/src/components/editor/actions.rs
··· 727 727 .map(|a| a.with_range(range)) 728 728 } 729 729 730 - /// Look up an action for the given key and modifiers, with the current range applied. 731 - #[allow(dead_code)] 732 - pub fn lookup_key(&self, key: Key, modifiers: Modifiers, range: Range) -> Option<EditorAction> { 733 - self.lookup(KeyCombo::with_modifiers(key, modifiers), range) 734 - } 735 - 736 730 /// Add or replace a keybinding. 737 731 #[allow(dead_code)] 738 732 pub fn bind(&mut self, combo: KeyCombo, action: EditorAction) {
+19 -17
crates/weaver-app/src/components/editor/collab.rs
··· 16 16 use dioxus::prelude::*; 17 17 18 18 #[cfg(target_arch = "wasm32")] 19 + use jacquard::smol_str::{format_smolstr, SmolStr}; 20 + #[cfg(target_arch = "wasm32")] 19 21 use jacquard::types::string::AtUri; 20 22 21 23 use weaver_common::transport::PresenceSnapshot; ··· 53 55 Initializing, 54 56 /// Creating session record on PDS 55 57 CreatingSession { 56 - node_id: String, 57 - relay_url: Option<String>, 58 + node_id: SmolStr, 59 + relay_url: Option<SmolStr>, 58 60 }, 59 61 /// Active collab session 60 62 Active { session_uri: AtUri<'static> }, 61 63 /// Error state 62 - Error(String), 64 + Error(SmolStr), 63 65 } 64 66 65 67 /// Coordinator component that bridges worker and PDS. ··· 147 149 148 150 // Initialize worker with current document snapshot 149 151 let snapshot = doc.export_snapshot(); 150 - let draft_key = resource_uri.clone(); // Use resource URI as the key 152 + let draft_key: SmolStr = resource_uri.clone().into(); // Use resource URI as the key 151 153 spawn(async move { 152 154 if let Some(ref mut sink) = *worker_sink.write() { 153 155 if let Err(e) = sink ··· 227 229 let uri = match AtUri::new(&resource_uri) { 228 230 Ok(u) => u.into_static(), 229 231 Err(e) => { 230 - let err = format!("Invalid resource URI: {e}"); 232 + let err = format_smolstr!("Invalid resource URI: {e}"); 231 233 debug_state 232 234 .with_mut(|ds| ds.last_error = Some(err.clone())); 233 235 state.set(CoordinatorState::Error(err)); ··· 239 241 let strong_ref = match fetcher.confirm_record_ref(&uri).await { 240 242 Ok(r) => r, 241 243 Err(e) => { 242 - let err = format!("Failed to get resource ref: {e}"); 244 + let err = format_smolstr!("Failed to get resource ref: {e}"); 243 245 debug_state 244 246 .with_mut(|ds| ds.last_error = Some(err.clone())); 245 247 state.set(CoordinatorState::Error(err)); ··· 322 324 }); 323 325 } 324 326 Err(e) => { 325 - let err = format!("Failed to create session: {e}"); 327 + let err = format_smolstr!("Failed to create session: {e}"); 326 328 debug_state 327 329 .with_mut(|ds| ds.last_error = Some(err.clone())); 328 330 state.set(CoordinatorState::Error(err)); ··· 367 369 let fetcher = fetcher.clone(); 368 370 369 371 // Get our profile info and send BroadcastJoin 370 - let (our_did, our_display_name) = match fetcher.current_did().await { 372 + let (our_did, our_display_name): (SmolStr, SmolStr) = match fetcher.current_did().await { 371 373 Some(did) => { 372 - let display_name = match fetcher.fetch_profile(&did.clone().into()).await { 374 + let display_name: SmolStr = match fetcher.fetch_profile(&did.clone().into()).await { 373 375 Ok(profile) => { 374 376 match &profile.inner { 375 377 ProfileDataViewInner::ProfileView(p) => { 376 - p.display_name.as_ref().map(|s| s.to_string()).unwrap_or_else(|| did.to_string()) 378 + p.display_name.as_ref().map(|s| s.as_ref().into()).unwrap_or_else(|| did.as_ref().into()) 377 379 } 378 380 ProfileDataViewInner::ProfileViewDetailed(p) => { 379 - p.display_name.as_ref().map(|s| s.to_string()).unwrap_or_else(|| did.to_string()) 381 + p.display_name.as_ref().map(|s| s.as_ref().into()).unwrap_or_else(|| did.as_ref().into()) 380 382 } 381 383 ProfileDataViewInner::TangledProfileView(p) => { 382 - p.handle.to_string() 384 + p.handle.as_ref().into() 383 385 } 384 - _ => did.to_string(), 386 + _ => did.as_ref().into(), 385 387 } 386 388 } 387 - Err(_) => did.to_string(), 389 + Err(_) => did.as_ref().into(), 388 390 }; 389 - (did.to_string(), display_name) 391 + (did.as_ref().into(), display_name) 390 392 } 391 393 None => { 392 394 tracing::warn!("CollabCoordinator: no current DID for Join message"); 393 - ("unknown".to_string(), "Anonymous".to_string()) 395 + ("unknown".into(), "Anonymous".into()) 394 396 } 395 397 }; 396 398 ··· 471 473 Ok(peers) => { 472 474 debug_state.with_mut(|ds| ds.discovered_peers = peers.len()); 473 475 if !peers.is_empty() { 474 - let peer_ids: Vec<String> = 476 + let peer_ids: Vec<SmolStr> = 475 477 peers.into_iter().map(|p| p.node_id).collect(); 476 478 477 479 if let Some(ref mut s) = *worker_sink.write() {
+4 -4
crates/weaver-app/src/components/editor/component.rs
··· 4 4 use jacquard::IntoStatic; 5 5 use jacquard::cowstr::ToCowStr; 6 6 use jacquard::identity::resolver::IdentityResolver; 7 - use jacquard::smol_str::SmolStr; 7 + use jacquard::smol_str::{SmolStr, ToSmolStr}; 8 8 use jacquard::types::aturi::AtUri; 9 9 use jacquard::types::blob::BlobRef; 10 10 use jacquard::types::ident::AtIdentifier; ··· 790 790 let _ = sink 791 791 .send(WorkerInput::Init { 792 792 snapshot, 793 - draft_key, 793 + draft_key: draft_key.into(), 794 794 }) 795 795 .await; 796 796 } ··· 846 846 }; 847 847 848 848 let cursor_offset = doc.cursor.read().offset; 849 - let editing_uri = doc.entry_ref().map(|r| r.uri.to_string()); 850 - let editing_cid = doc.entry_ref().map(|r| r.cid.to_string()); 849 + let editing_uri = doc.entry_ref().map(|r| r.uri.to_smolstr()); 850 + let editing_cid = doc.entry_ref().map(|r| r.cid.to_smolstr()); 851 851 852 852 let sink_clone = worker_sink.clone(); 853 853
+12 -7
crates/weaver-app/src/components/editor/storage.rs
··· 20 20 #[cfg(all(target_family = "wasm", target_os = "unknown"))] 21 21 use gloo_storage::{LocalStorage, Storage}; 22 22 use jacquard::IntoStatic; 23 + use jacquard::smol_str::{SmolStr, ToSmolStr}; 23 24 use jacquard::types::string::{AtUri, Cid}; 24 25 use weaver_api::com_atproto::repo::strong_ref::StrongRef; 25 26 use loro::cursor::Cursor; ··· 49 50 50 51 /// Entry title (for debugging/display in drafts list) 51 52 #[serde(default)] 52 - pub title: String, 53 + pub title: SmolStr, 53 54 54 55 /// Base64-encoded CRDT snapshot (contains ALL fields including embeds) 55 56 #[serde(default, skip_serializing_if = "Option::is_none")] ··· 65 66 66 67 /// AT-URI if editing an existing entry (None for new entries) 67 68 #[serde(default, skip_serializing_if = "Option::is_none")] 68 - pub editing_uri: Option<String>, 69 + pub editing_uri: Option<SmolStr>, 69 70 70 71 /// CID of the entry if editing an existing entry 71 72 #[serde(default, skip_serializing_if = "Option::is_none")] 72 - pub editing_cid: Option<String>, 73 + pub editing_cid: Option<SmolStr>, 73 74 } 74 75 75 76 /// Build the full storage key from a draft key. ··· 97 98 98 99 let snapshot = EditorSnapshot { 99 100 content: doc.content(), 100 - title: doc.title(), 101 + title: doc.title().into(), 101 102 snapshot: snapshot_b64, 102 103 cursor: doc.loro_cursor().cloned(), 103 104 cursor_offset: doc.cursor.read().offset, 104 - editing_uri: doc.entry_ref().map(|r| r.uri.to_string()), 105 - editing_cid: doc.entry_ref().map(|r| r.cid.to_string()), 105 + editing_uri: doc.entry_ref().map(|r| r.uri.to_smolstr()), 106 + editing_cid: doc.entry_ref().map(|r| r.cid.to_smolstr()), 106 107 }; 107 108 108 109 let write_start = crate::perf::now(); ··· 235 236 // Try to load just the metadata 236 237 if let Ok(snapshot) = LocalStorage::get::<EditorSnapshot>(&key) { 237 238 let draft_key = key.strip_prefix(DRAFT_KEY_PREFIX).unwrap_or(&key); 238 - drafts.push((draft_key.to_string(), snapshot.title, snapshot.editing_uri)); 239 + drafts.push(( 240 + draft_key.to_string(), 241 + snapshot.title.to_string(), 242 + snapshot.editing_uri.map(|s| s.to_string()), 243 + )); 239 244 } 240 245 } 241 246 }
+6 -51
crates/weaver-app/src/components/editor/sync.rs
··· 239 239 } 240 240 } 241 241 242 - /// Extract (authority, rkey) from a canonical draft key (synthetic AT-URI). 243 - /// 244 - /// Parses `at://{authority}/sh.weaver.edit.draft/{rkey}` and returns the components. 245 - /// Authority can be a DID or handle. 246 - #[allow(dead_code)] 247 - pub fn parse_draft_key( 248 - draft_key: &str, 249 - ) -> Option<(jacquard::types::ident::AtIdentifier<'static>, String)> { 250 - let uri = AtUri::new(draft_key).ok()?; 251 - let authority = uri.authority().clone().into_static(); 252 - let rkey = uri.rkey()?.0.as_str().to_string(); 253 - Some((authority, rkey)) 254 - } 255 - 256 242 /// Result of a sync operation. 257 243 #[derive(Clone, Debug)] 258 244 pub enum SyncResult { ··· 268 254 }, 269 255 /// No changes to sync 270 256 NoChanges, 271 - } 272 - 273 - /// Find the edit root for an entry using constellation backlinks. 274 - /// 275 - /// Queries constellation for `sh.weaver.edit.root` records that reference 276 - /// the given entry URI via the `.doc.value.entry.uri` path. 277 - #[allow(dead_code)] 278 - pub async fn find_edit_root_for_entry( 279 - fetcher: &Fetcher, 280 - entry_uri: &AtUri<'_>, 281 - ) -> Result<Option<RecordId<'static>>, WeaverError> { 282 - let constellation_url = Url::parse(CONSTELLATION_URL) 283 - .map_err(|e| WeaverError::InvalidNotebook(format!("Invalid constellation URL: {}", e)))?; 284 - 285 - let query = GetBacklinksQuery { 286 - subject: Uri::At(entry_uri.clone().into_static()), 287 - source: format_smolstr!("{}:doc.value.entry.uri", ROOT_NSID).into(), 288 - cursor: None, 289 - did: vec![], 290 - limit: 1, 291 - }; 292 - 293 - let response = fetcher 294 - .client 295 - .xrpc(constellation_url) 296 - .send(&query) 297 - .await 298 - .map_err(|e| WeaverError::InvalidNotebook(format!("Constellation query failed: {}", e)))?; 299 - 300 - let output = response.into_output().map_err(|e| { 301 - WeaverError::InvalidNotebook(format!("Failed to parse constellation response: {}", e)) 302 - })?; 303 - 304 - Ok(output.records.into_iter().next().map(|r| r.into_static())) 305 257 } 306 258 307 259 /// Find ALL edit.root records across collaborators for an entry. ··· 551 503 } 552 504 553 505 /// Find all diffs for a root record using constellation backlinks. 554 - #[allow(dead_code)] 555 506 pub async fn find_diffs_for_root( 556 507 fetcher: &Fetcher, 557 508 root_uri: &AtUri<'_>, ··· 916 867 fetcher: &Fetcher, 917 868 entry_uri: &AtUri<'_>, 918 869 ) -> Result<Option<PdsEditState>, WeaverError> { 919 - // Find the edit root for this entry 920 - let root_id = match find_edit_root_for_entry(fetcher, entry_uri).await? { 870 + // Find the edit root for this entry (take first if multiple exist) 871 + let root_id = match find_all_edit_roots_for_entry(fetcher, entry_uri) 872 + .await? 873 + .into_iter() 874 + .next() 875 + { 921 876 Some(id) => id, 922 877 None => return Ok(None), 923 878 };
+408 -294
crates/weaver-app/src/components/editor/worker.rs
··· 18 18 #[cfg(all(target_family = "wasm", target_os = "unknown"))] 19 19 use jacquard::smol_str::format_smolstr; 20 20 21 + use jacquard::smol_str::SmolStr; 22 + 21 23 /// Input messages to the editor worker. 22 24 #[derive(Serialize, Deserialize, Debug, Clone)] 23 25 pub enum WorkerInput { ··· 26 28 /// Full Loro snapshot bytes 27 29 snapshot: Vec<u8>, 28 30 /// Draft key for storage 29 - draft_key: String, 31 + draft_key: SmolStr, 30 32 }, 31 33 /// Apply incremental Loro updates to the shadow document. 32 34 ApplyUpdates { ··· 38 40 /// Current cursor position (for snapshot metadata) 39 41 cursor_offset: usize, 40 42 /// Editing URI if editing existing entry 41 - editing_uri: Option<String>, 43 + editing_uri: Option<SmolStr>, 42 44 /// Editing CID if editing existing entry 43 - editing_cid: Option<String>, 45 + editing_cid: Option<SmolStr>, 44 46 }, 45 47 /// Start collab session (worker will spawn CollabNode) 46 48 StartCollab { 47 49 /// blake3 hash of resource URI (32 bytes) 48 50 topic: [u8; 32], 49 51 /// Bootstrap peer node IDs (z-base32 strings) 50 - bootstrap_peers: Vec<String>, 52 + bootstrap_peers: Vec<SmolStr>, 51 53 }, 52 54 /// Loro updates from local edits (forward to gossip) 53 55 BroadcastUpdate { ··· 57 59 /// New peers discovered by main thread 58 60 AddPeers { 59 61 /// Node ID strings 60 - peers: Vec<String>, 62 + peers: Vec<SmolStr>, 61 63 }, 62 64 /// Announce ourselves to peers (sent after AddPeers) 63 65 BroadcastJoin { 64 66 /// Our DID 65 - did: String, 67 + did: SmolStr, 66 68 /// Our display name 67 - display_name: String, 69 + display_name: SmolStr, 68 70 }, 69 71 /// Local cursor position changed 70 72 BroadcastCursor { ··· 85 87 /// Snapshot export completed. 86 88 Snapshot { 87 89 /// Draft key for storage 88 - draft_key: String, 90 + draft_key: SmolStr, 89 91 /// Base64-encoded Loro snapshot 90 92 b64_snapshot: String, 91 93 /// Human-readable content (for debugging) 92 94 content: String, 93 95 /// Entry title 94 - title: String, 96 + title: SmolStr, 95 97 /// Cursor offset 96 98 cursor_offset: usize, 97 99 /// Editing URI 98 - editing_uri: Option<String>, 100 + editing_uri: Option<SmolStr>, 99 101 /// Editing CID 100 - editing_cid: Option<String>, 102 + editing_cid: Option<SmolStr>, 101 103 /// Export timing in ms 102 104 export_ms: f64, 103 105 /// Encode timing in ms 104 106 encode_ms: f64, 105 107 }, 106 108 /// Error occurred. 107 - Error { message: String }, 109 + Error { message: SmolStr }, 108 110 /// Collab node ready, here's info for session record 109 111 CollabReady { 110 112 /// Node ID (z-base32 string) 111 - node_id: String, 113 + node_id: SmolStr, 112 114 /// Relay URL for browser connectivity 113 - relay_url: Option<String>, 115 + relay_url: Option<SmolStr>, 114 116 }, 115 117 /// Collab session joined successfully 116 118 CollabJoined, ··· 132 134 use super::*; 133 135 use futures_util::sink::SinkExt; 134 136 use futures_util::stream::StreamExt; 135 - use gloo_worker::reactor::{reactor, ReactorScope}; 137 + use gloo_worker::reactor::{ReactorScope, reactor}; 136 138 use weaver_common::transport::CollaboratorInfo; 137 139 138 140 #[cfg(feature = "collab-worker")] 141 + use jacquard::smol_str::ToSmolStr; 142 + #[cfg(feature = "collab-worker")] 139 143 use std::sync::Arc; 140 144 #[cfg(feature = "collab-worker")] 141 145 use weaver_common::transport::{ ··· 155 159 #[reactor] 156 160 pub async fn EditorReactor(mut scope: ReactorScope<WorkerInput, WorkerOutput>) { 157 161 let mut doc: Option<loro::LoroDoc> = None; 158 - let mut draft_key = String::new(); 162 + let mut draft_key = SmolStr::default(); 159 163 160 164 // Collab state (only used when collab-worker feature enabled) 161 165 #[cfg(feature = "collab-worker")] ··· 196 200 } 197 201 } 198 202 CollabEvent::PresenceChanged(snapshot) => { 199 - if let Err(e) = scope.send(WorkerOutput::PresenceUpdate(snapshot)).await { 200 - tracing::error!("Failed to send PresenceUpdate to coordinator: {e}"); 203 + if let Err(e) = scope.send(WorkerOutput::PresenceUpdate(snapshot)).await 204 + { 205 + tracing::error!( 206 + "Failed to send PresenceUpdate to coordinator: {e}" 207 + ); 201 208 } 202 209 } 203 210 CollabEvent::PeerConnected => { ··· 218 225 // Fall through to message handling below 219 226 tracing::debug!(?msg, "Worker: received message"); 220 227 match msg { 221 - WorkerInput::Init { 222 - snapshot, 223 - draft_key: key, 224 - } => { 225 - let new_doc = loro::LoroDoc::new(); 226 - if !snapshot.is_empty() { 227 - if let Err(e) = new_doc.import(&snapshot) { 228 - if let Err(send_err) = scope 229 - .send(WorkerOutput::Error { 230 - message: format_smolstr!("Failed to import snapshot: {e}").to_string(), 231 - }) 232 - .await 233 - { 234 - tracing::error!("Failed to send Error to coordinator: {send_err}"); 228 + WorkerInput::Init { 229 + snapshot, 230 + draft_key: key, 231 + } => { 232 + let new_doc = loro::LoroDoc::new(); 233 + if !snapshot.is_empty() { 234 + if let Err(e) = new_doc.import(&snapshot) { 235 + if let Err(send_err) = scope 236 + .send(WorkerOutput::Error { 237 + message: format_smolstr!( 238 + "Failed to import snapshot: {e}" 239 + ), 240 + }) 241 + .await 242 + { 243 + tracing::error!( 244 + "Failed to send Error to coordinator: {send_err}" 245 + ); 246 + } 247 + continue; 248 + } 249 + } 250 + doc = Some(new_doc); 251 + draft_key = key; 252 + if let Err(e) = scope.send(WorkerOutput::Ready).await { 253 + tracing::error!("Failed to send Ready to coordinator: {e}"); 235 254 } 236 - continue; 237 255 } 238 - } 239 - doc = Some(new_doc); 240 - draft_key = key; 241 - if let Err(e) = scope.send(WorkerOutput::Ready).await { 242 - tracing::error!("Failed to send Ready to coordinator: {e}"); 243 - } 244 - } 245 256 246 - WorkerInput::ApplyUpdates { updates } => { 247 - if let Some(ref doc) = doc { 248 - if let Err(e) = doc.import(&updates) { 249 - tracing::warn!("Worker failed to import updates: {e}"); 257 + WorkerInput::ApplyUpdates { updates } => { 258 + if let Some(ref doc) = doc { 259 + if let Err(e) = doc.import(&updates) { 260 + tracing::warn!("Worker failed to import updates: {e}"); 261 + } 262 + } 250 263 } 251 - } 252 - } 253 264 254 - WorkerInput::ExportSnapshot { 255 - cursor_offset, 256 - editing_uri, 257 - editing_cid, 258 - } => { 259 - let Some(ref doc) = doc else { 260 - if let Err(e) = scope 261 - .send(WorkerOutput::Error { 262 - message: "No document initialized".into(), 263 - }) 264 - .await 265 - { 266 - tracing::error!("Failed to send Error to coordinator: {e}"); 267 - } 268 - continue; 269 - }; 265 + WorkerInput::ExportSnapshot { 266 + cursor_offset, 267 + editing_uri, 268 + editing_cid, 269 + } => { 270 + let Some(ref doc) = doc else { 271 + if let Err(e) = scope 272 + .send(WorkerOutput::Error { 273 + message: "No document initialized".into(), 274 + }) 275 + .await 276 + { 277 + tracing::error!("Failed to send Error to coordinator: {e}"); 278 + } 279 + continue; 280 + }; 270 281 271 - let export_start = crate::perf::now(); 272 - let snapshot_bytes = match doc.export(loro::ExportMode::Snapshot) { 273 - Ok(bytes) => bytes, 274 - Err(e) => { 275 - if let Err(send_err) = scope 276 - .send(WorkerOutput::Error { 277 - message: format_smolstr!("Export failed: {e}").to_string(), 282 + let export_start = crate::perf::now(); 283 + let snapshot_bytes = match doc.export(loro::ExportMode::Snapshot) { 284 + Ok(bytes) => bytes, 285 + Err(e) => { 286 + if let Err(send_err) = scope 287 + .send(WorkerOutput::Error { 288 + message: format_smolstr!("Export failed: {e}"), 289 + }) 290 + .await 291 + { 292 + tracing::error!( 293 + "Failed to send Error to coordinator: {send_err}" 294 + ); 295 + } 296 + continue; 297 + } 298 + }; 299 + let export_ms = crate::perf::now() - export_start; 300 + 301 + let encode_start = crate::perf::now(); 302 + let b64_snapshot = BASE64.encode(&snapshot_bytes); 303 + let encode_ms = crate::perf::now() - encode_start; 304 + 305 + let content = doc.get_text("content").to_string(); 306 + let title: SmolStr = doc.get_text("title").to_string().into(); 307 + 308 + if let Err(e) = scope 309 + .send(WorkerOutput::Snapshot { 310 + draft_key: draft_key.clone(), 311 + b64_snapshot, 312 + content, 313 + title, 314 + cursor_offset, 315 + editing_uri, 316 + editing_cid, 317 + export_ms, 318 + encode_ms, 278 319 }) 279 320 .await 280 321 { 281 - tracing::error!("Failed to send Error to coordinator: {send_err}"); 322 + tracing::error!("Failed to send Snapshot to coordinator: {e}"); 282 323 } 283 - continue; 284 324 } 285 - }; 286 - let export_ms = crate::perf::now() - export_start; 287 325 288 - let encode_start = crate::perf::now(); 289 - let b64_snapshot = BASE64.encode(&snapshot_bytes); 290 - let encode_ms = crate::perf::now() - encode_start; 291 - 292 - let content = doc.get_text("content").to_string(); 293 - let title = doc.get_text("title").to_string(); 326 + // ============================================================ 327 + // Collab handlers - full impl when collab-worker feature enabled 328 + // ============================================================ 329 + #[cfg(feature = "collab-worker")] 330 + WorkerInput::StartCollab { 331 + topic, 332 + bootstrap_peers, 333 + } => { 334 + // Spawn CollabNode 335 + let node = match CollabNode::spawn(None).await { 336 + Ok(n) => n, 337 + Err(e) => { 338 + if let Err(send_err) = scope 339 + .send(WorkerOutput::Error { 340 + message: format_smolstr!( 341 + "Failed to spawn CollabNode: {e}" 342 + ), 343 + }) 344 + .await 345 + { 346 + tracing::error!( 347 + "Failed to send Error to coordinator: {send_err}" 348 + ); 349 + } 350 + continue; 351 + } 352 + }; 294 353 295 - if let Err(e) = scope 296 - .send(WorkerOutput::Snapshot { 297 - draft_key: draft_key.clone(), 298 - b64_snapshot, 299 - content, 300 - title, 301 - cursor_offset, 302 - editing_uri, 303 - editing_cid, 304 - export_ms, 305 - encode_ms, 306 - }) 307 - .await 308 - { 309 - tracing::error!("Failed to send Snapshot to coordinator: {e}"); 310 - } 311 - } 354 + // Wait for relay connection 355 + let relay_url = node.wait_for_relay().await; 356 + let node_id = node.node_id_string(); 312 357 313 - // ============================================================ 314 - // Collab handlers - full impl when collab-worker feature enabled 315 - // ============================================================ 316 - #[cfg(feature = "collab-worker")] 317 - WorkerInput::StartCollab { 318 - topic, 319 - bootstrap_peers, 320 - } => { 321 - // Spawn CollabNode 322 - let node = match CollabNode::spawn(None).await { 323 - Ok(n) => n, 324 - Err(e) => { 325 - if let Err(send_err) = scope 326 - .send(WorkerOutput::Error { 327 - message: format_smolstr!("Failed to spawn CollabNode: {e}").to_string(), 358 + // Send ready so main thread can create session record 359 + if let Err(e) = scope 360 + .send(WorkerOutput::CollabReady { 361 + node_id, 362 + relay_url: Some(relay_url), 328 363 }) 329 364 .await 330 365 { 331 - tracing::error!("Failed to send Error to coordinator: {send_err}"); 366 + tracing::error!("Failed to send CollabReady to coordinator: {e}"); 332 367 } 333 - continue; 334 - } 335 - }; 336 368 337 - // Wait for relay connection 338 - let relay_url = node.wait_for_relay().await; 339 - let node_id = node.node_id_string(); 369 + collab_node = Some(node.clone()); 340 370 341 - // Send ready so main thread can create session record 342 - if let Err(e) = scope 343 - .send(WorkerOutput::CollabReady { 344 - node_id, 345 - relay_url: Some(relay_url), 346 - }) 347 - .await 348 - { 349 - tracing::error!("Failed to send CollabReady to coordinator: {e}"); 350 - } 371 + // Parse bootstrap peers 372 + let peers: Vec<_> = bootstrap_peers 373 + .iter() 374 + .filter_map(|s| parse_node_id(s).ok()) 375 + .collect(); 351 376 352 - collab_node = Some(node.clone()); 377 + // Join gossip session 378 + let topic_id = TopicId::from_bytes(topic); 379 + match CollabSession::join(node, topic_id, peers).await { 380 + Ok((session, mut events)) => { 381 + let session = Arc::new(session); 382 + collab_session = Some(session.clone()); 383 + if let Err(e) = scope.send(WorkerOutput::CollabJoined).await { 384 + tracing::error!( 385 + "Failed to send CollabJoined to coordinator: {e}" 386 + ); 387 + } 353 388 354 - // Parse bootstrap peers 355 - let peers: Vec<_> = bootstrap_peers 356 - .iter() 357 - .filter_map(|s| parse_node_id(s).ok()) 358 - .collect(); 389 + // NOTE: Don't broadcast Join here - wait for BroadcastJoin message 390 + // after peers have been added via AddPeers 359 391 360 - // Join gossip session 361 - let topic_id = TopicId::from_bytes(topic); 362 - match CollabSession::join(node, topic_id, peers).await { 363 - Ok((session, mut events)) => { 364 - let session = Arc::new(session); 365 - collab_session = Some(session.clone()); 366 - if let Err(e) = scope.send(WorkerOutput::CollabJoined).await { 367 - tracing::error!("Failed to send CollabJoined to coordinator: {e}"); 368 - } 392 + // Create channel for events from spawned task 393 + let (event_tx, event_rx) = 394 + tokio::sync::mpsc::unbounded_channel(); 395 + collab_event_rx = Some(event_rx); 369 396 370 - // NOTE: Don't broadcast Join here - wait for BroadcastJoin message 371 - // after peers have been added via AddPeers 372 - 373 - // Create channel for events from spawned task 374 - let (event_tx, event_rx) = tokio::sync::mpsc::unbounded_channel(); 375 - collab_event_rx = Some(event_rx); 397 + // Spawn event handler task that sends via channel 398 + wasm_bindgen_futures::spawn_local(async move { 399 + let mut presence = PresenceTracker::new(); 376 400 377 - // Spawn event handler task that sends via channel 378 - wasm_bindgen_futures::spawn_local(async move { 379 - let mut presence = PresenceTracker::new(); 380 - 381 - while let Some(Ok(event)) = events.next().await { 382 - match event { 383 - SessionEvent::Message { from, message } => { 384 - match message { 385 - CollabMessage::LoroUpdate { data, .. } => { 386 - if event_tx.send(CollabEvent::RemoteUpdates { data }).is_err() { 387 - tracing::warn!("Collab event channel closed"); 388 - return; 389 - } 390 - } 391 - CollabMessage::Join { did, display_name } => { 392 - tracing::info!(%from, %did, %display_name, "Received Join message"); 393 - presence.add_collaborator(from, did, display_name); 394 - if event_tx.send(CollabEvent::PresenceChanged( 395 - presence_to_snapshot(&presence), 396 - )).is_err() { 397 - tracing::warn!("Collab event channel closed"); 398 - return; 401 + while let Some(Ok(event)) = events.next().await { 402 + match event { 403 + SessionEvent::Message { from, message } => { 404 + match message { 405 + CollabMessage::LoroUpdate { 406 + data, .. 407 + } => { 408 + if event_tx 409 + .send(CollabEvent::RemoteUpdates { 410 + data, 411 + }) 412 + .is_err() 413 + { 414 + tracing::warn!( 415 + "Collab event channel closed" 416 + ); 417 + return; 418 + } 419 + } 420 + CollabMessage::Join { 421 + did, 422 + display_name, 423 + } => { 424 + tracing::info!(%from, %did, %display_name, "Received Join message"); 425 + presence.add_collaborator( 426 + from, 427 + did, 428 + display_name, 429 + ); 430 + if event_tx 431 + .send(CollabEvent::PresenceChanged( 432 + presence_to_snapshot(&presence), 433 + )) 434 + .is_err() 435 + { 436 + tracing::warn!( 437 + "Collab event channel closed" 438 + ); 439 + return; 440 + } 441 + } 442 + CollabMessage::Leave { .. } => { 443 + presence.remove_collaborator(&from); 444 + if event_tx 445 + .send(CollabEvent::PresenceChanged( 446 + presence_to_snapshot(&presence), 447 + )) 448 + .is_err() 449 + { 450 + tracing::warn!( 451 + "Collab event channel closed" 452 + ); 453 + return; 454 + } 455 + } 456 + CollabMessage::Cursor { 457 + position, 458 + selection, 459 + .. 460 + } => { 461 + // Note: cursor updates require the collaborator to exist 462 + // (added via Join message) 463 + let exists = presence.contains(&from); 464 + tracing::debug!(%from, position, ?selection, exists, "Received Cursor message"); 465 + presence.update_cursor( 466 + &from, position, selection, 467 + ); 468 + if event_tx 469 + .send(CollabEvent::PresenceChanged( 470 + presence_to_snapshot(&presence), 471 + )) 472 + .is_err() 473 + { 474 + tracing::warn!( 475 + "Collab event channel closed" 476 + ); 477 + return; 478 + } 479 + } 480 + _ => {} 399 481 } 400 482 } 401 - CollabMessage::Leave { .. } => { 402 - presence.remove_collaborator(&from); 403 - if event_tx.send(CollabEvent::PresenceChanged( 404 - presence_to_snapshot(&presence), 405 - )).is_err() { 406 - tracing::warn!("Collab event channel closed"); 483 + SessionEvent::PeerJoined(peer) => { 484 + tracing::info!(%peer, "PeerJoined - notifying coordinator"); 485 + // Notify coordinator so it can send BroadcastJoin 486 + // Don't add to presence yet - wait for their Join message 487 + if event_tx 488 + .send(CollabEvent::PeerConnected) 489 + .is_err() 490 + { 491 + tracing::warn!( 492 + "Collab event channel closed" 493 + ); 407 494 return; 408 495 } 409 496 } 410 - CollabMessage::Cursor { 411 - position, 412 - selection, 413 - .. 414 - } => { 415 - // Note: cursor updates require the collaborator to exist 416 - // (added via Join message) 417 - let exists = presence.contains(&from); 418 - tracing::debug!(%from, position, ?selection, exists, "Received Cursor message"); 419 - presence.update_cursor(&from, position, selection); 420 - if event_tx.send(CollabEvent::PresenceChanged( 421 - presence_to_snapshot(&presence), 422 - )).is_err() { 423 - tracing::warn!("Collab event channel closed"); 497 + SessionEvent::PeerLeft(peer) => { 498 + presence.remove_collaborator(&peer); 499 + if event_tx 500 + .send(CollabEvent::PresenceChanged( 501 + presence_to_snapshot(&presence), 502 + )) 503 + .is_err() 504 + { 505 + tracing::warn!( 506 + "Collab event channel closed" 507 + ); 424 508 return; 425 509 } 426 510 } 427 - _ => {} 428 - } 429 - } 430 - SessionEvent::PeerJoined(peer) => { 431 - tracing::info!(%peer, "PeerJoined - notifying coordinator"); 432 - // Notify coordinator so it can send BroadcastJoin 433 - // Don't add to presence yet - wait for their Join message 434 - if event_tx.send(CollabEvent::PeerConnected).is_err() { 435 - tracing::warn!("Collab event channel closed"); 436 - return; 437 - } 438 - } 439 - SessionEvent::PeerLeft(peer) => { 440 - presence.remove_collaborator(&peer); 441 - if event_tx.send(CollabEvent::PresenceChanged( 442 - presence_to_snapshot(&presence), 443 - )).is_err() { 444 - tracing::warn!("Collab event channel closed"); 445 - return; 511 + SessionEvent::Joined => {} 446 512 } 447 513 } 448 - SessionEvent::Joined => {} 514 + }); 515 + } 516 + Err(e) => { 517 + if let Err(send_err) = scope 518 + .send(WorkerOutput::Error { 519 + message: format_smolstr!("Failed to join session: {e}"), 520 + }) 521 + .await 522 + { 523 + tracing::error!( 524 + "Failed to send Error to coordinator: {send_err}" 525 + ); 449 526 } 450 527 } 451 - }); 452 - } 453 - Err(e) => { 454 - if let Err(send_err) = scope 455 - .send(WorkerOutput::Error { 456 - message: format_smolstr!("Failed to join session: {e}").to_string(), 457 - }) 458 - .await 459 - { 460 - tracing::error!("Failed to send Error to coordinator: {send_err}"); 461 528 } 462 529 } 463 - } 464 - } 465 530 466 - #[cfg(feature = "collab-worker")] 467 - WorkerInput::BroadcastUpdate { data } => { 468 - if let Some(ref session) = collab_session { 469 - let msg = CollabMessage::LoroUpdate { 470 - data, 471 - version: vec![], 472 - }; 473 - if let Err(e) = session.broadcast(&msg).await { 474 - tracing::warn!("Broadcast failed: {e}"); 531 + #[cfg(feature = "collab-worker")] 532 + WorkerInput::BroadcastUpdate { data } => { 533 + if let Some(ref session) = collab_session { 534 + let msg = CollabMessage::LoroUpdate { 535 + data, 536 + version: vec![], 537 + }; 538 + if let Err(e) = session.broadcast(&msg).await { 539 + tracing::warn!("Broadcast failed: {e}"); 540 + } 541 + } 475 542 } 476 - } 477 - } 478 543 479 - #[cfg(feature = "collab-worker")] 480 - WorkerInput::BroadcastCursor { position, selection } => { 481 - if let Some(ref session) = collab_session { 482 - tracing::debug!(position, ?selection, "Worker: broadcasting cursor"); 483 - let msg = CollabMessage::Cursor { 544 + #[cfg(feature = "collab-worker")] 545 + WorkerInput::BroadcastCursor { 484 546 position, 485 547 selection, 486 - color: OUR_COLOR, 487 - }; 488 - if let Err(e) = session.broadcast(&msg).await { 489 - tracing::warn!("Cursor broadcast failed: {e}"); 548 + } => { 549 + if let Some(ref session) = collab_session { 550 + tracing::debug!( 551 + position, 552 + ?selection, 553 + "Worker: broadcasting cursor" 554 + ); 555 + let msg = CollabMessage::Cursor { 556 + position, 557 + selection, 558 + color: OUR_COLOR, 559 + }; 560 + if let Err(e) = session.broadcast(&msg).await { 561 + tracing::warn!("Cursor broadcast failed: {e}"); 562 + } 563 + } else { 564 + tracing::debug!( 565 + position, 566 + ?selection, 567 + "Worker: BroadcastCursor but no session" 568 + ); 569 + } 490 570 } 491 - } else { 492 - tracing::debug!(position, ?selection, "Worker: BroadcastCursor but no session"); 493 - } 494 - } 495 571 496 - #[cfg(feature = "collab-worker")] 497 - WorkerInput::AddPeers { peers } => { 498 - tracing::info!(count = peers.len(), "Worker: received AddPeers"); 499 - if let Some(ref session) = collab_session { 500 - let peer_ids: Vec<_> = peers 572 + #[cfg(feature = "collab-worker")] 573 + WorkerInput::AddPeers { peers } => { 574 + tracing::info!(count = peers.len(), "Worker: received AddPeers"); 575 + if let Some(ref session) = collab_session { 576 + let peer_ids: Vec<_> = peers 501 577 .iter() 502 578 .filter_map(|s| { 503 579 match parse_node_id(s) { ··· 509 585 } 510 586 }) 511 587 .collect(); 512 - tracing::info!(parsed_count = peer_ids.len(), "Worker: joining peers"); 513 - if let Err(e) = session.join_peers(peer_ids).await { 514 - tracing::warn!("Failed to add peers: {e}"); 588 + tracing::info!( 589 + parsed_count = peer_ids.len(), 590 + "Worker: joining peers" 591 + ); 592 + if let Err(e) = session.join_peers(peer_ids).await { 593 + tracing::warn!("Failed to add peers: {e}"); 594 + } 595 + } else { 596 + tracing::warn!("Worker: AddPeers but no collab_session"); 597 + } 515 598 } 516 - } else { 517 - tracing::warn!("Worker: AddPeers but no collab_session"); 518 - } 519 - } 520 599 521 - #[cfg(feature = "collab-worker")] 522 - WorkerInput::BroadcastJoin { did, display_name } => { 523 - if let Some(ref session) = collab_session { 524 - let join_msg = CollabMessage::Join { did, display_name }; 525 - if let Err(e) = session.broadcast(&join_msg).await { 526 - tracing::warn!("Failed to broadcast Join: {e}"); 600 + #[cfg(feature = "collab-worker")] 601 + WorkerInput::BroadcastJoin { did, display_name } => { 602 + if let Some(ref session) = collab_session { 603 + let join_msg = CollabMessage::Join { did, display_name }; 604 + if let Err(e) = session.broadcast(&join_msg).await { 605 + tracing::warn!("Failed to broadcast Join: {e}"); 606 + } 607 + } 527 608 } 528 - } 529 - } 530 609 531 - #[cfg(feature = "collab-worker")] 532 - WorkerInput::StopCollab => { 533 - collab_session = None; 534 - collab_node = None; 535 - collab_event_rx = None; 536 - if let Err(e) = scope.send(WorkerOutput::CollabStopped).await { 537 - tracing::error!("Failed to send CollabStopped to coordinator: {e}"); 538 - } 539 - } 540 - 610 + #[cfg(feature = "collab-worker")] 611 + WorkerInput::StopCollab => { 612 + collab_session = None; 613 + collab_node = None; 614 + collab_event_rx = None; 615 + if let Err(e) = scope.send(WorkerOutput::CollabStopped).await { 616 + tracing::error!("Failed to send CollabStopped to coordinator: {e}"); 617 + } 618 + } 541 619 } // end match msg 542 620 } // end RaceResult::CoordinatorMsg(Some(msg)) 543 621 } // end match race_result ··· 548 626 let Some(msg) = scope.next().await else { break }; 549 627 tracing::debug!(?msg, "Worker: received message"); 550 628 match msg { 551 - WorkerInput::Init { snapshot, draft_key: key } => { 629 + WorkerInput::Init { 630 + snapshot, 631 + draft_key: key, 632 + } => { 552 633 let new_doc = loro::LoroDoc::new(); 553 634 if !snapshot.is_empty() { 554 635 if let Err(e) = new_doc.import(&snapshot) { 555 636 if let Err(send_err) = scope 556 637 .send(WorkerOutput::Error { 557 - message: format_smolstr!("Failed to import snapshot: {e}").to_string(), 638 + message: format_smolstr!("Failed to import snapshot: {e}"), 558 639 }) 559 640 .await 560 641 { 561 - tracing::error!("Failed to send Error to coordinator: {send_err}"); 642 + tracing::error!( 643 + "Failed to send Error to coordinator: {send_err}" 644 + ); 562 645 } 563 646 continue; 564 647 } ··· 576 659 } 577 660 } 578 661 } 579 - WorkerInput::ExportSnapshot { cursor_offset, editing_uri, editing_cid } => { 662 + WorkerInput::ExportSnapshot { 663 + cursor_offset, 664 + editing_uri, 665 + editing_cid, 666 + } => { 580 667 let Some(ref doc) = doc else { 581 - if let Err(e) = scope.send(WorkerOutput::Error { message: "No document initialized".into() }).await { 668 + if let Err(e) = scope 669 + .send(WorkerOutput::Error { 670 + message: "No document initialized".into(), 671 + }) 672 + .await 673 + { 582 674 tracing::error!("Failed to send Error to coordinator: {e}"); 583 675 } 584 676 continue; ··· 587 679 let snapshot_bytes = match doc.export(loro::ExportMode::Snapshot) { 588 680 Ok(bytes) => bytes, 589 681 Err(e) => { 590 - if let Err(send_err) = scope.send(WorkerOutput::Error { message: format_smolstr!("Export failed: {e}").to_string() }).await { 591 - tracing::error!("Failed to send Error to coordinator: {send_err}"); 682 + if let Err(send_err) = scope 683 + .send(WorkerOutput::Error { 684 + message: format_smolstr!("Export failed: {e}"), 685 + }) 686 + .await 687 + { 688 + tracing::error!( 689 + "Failed to send Error to coordinator: {send_err}" 690 + ); 592 691 } 593 692 continue; 594 693 } ··· 598 697 let b64_snapshot = BASE64.encode(&snapshot_bytes); 599 698 let encode_ms = crate::perf::now() - encode_start; 600 699 let content = doc.get_text("content").to_string(); 601 - let title = doc.get_text("title").to_string(); 602 - if let Err(e) = scope.send(WorkerOutput::Snapshot { 603 - draft_key: draft_key.clone(), b64_snapshot, content, title, 604 - cursor_offset, editing_uri, editing_cid, export_ms, encode_ms, 605 - }).await { 700 + let title: SmolStr = doc.get_text("title").to_string().into(); 701 + if let Err(e) = scope 702 + .send(WorkerOutput::Snapshot { 703 + draft_key: draft_key.clone(), 704 + b64_snapshot, 705 + content, 706 + title, 707 + cursor_offset, 708 + editing_uri, 709 + editing_cid, 710 + export_ms, 711 + encode_ms, 712 + }) 713 + .await 714 + { 606 715 tracing::error!("Failed to send Snapshot to coordinator: {e}"); 607 716 } 608 717 } 609 718 // Collab stubs for non-collab-worker build 610 719 WorkerInput::StartCollab { .. } => { 611 - if let Err(e) = scope.send(WorkerOutput::Error { message: "Collab not enabled".into() }).await { 720 + if let Err(e) = scope 721 + .send(WorkerOutput::Error { 722 + message: "Collab not enabled".into(), 723 + }) 724 + .await 725 + { 612 726 tracing::error!("Failed to send Error to coordinator: {e}"); 613 727 } 614 728 } ··· 632 746 let collaborators = tracker 633 747 .collaborators() 634 748 .map(|c| CollaboratorInfo { 635 - node_id: c.node_id.to_string(), 749 + node_id: c.node_id.to_smolstr(), 636 750 did: c.did.clone(), 637 751 display_name: c.display_name.clone(), 638 752 color: c.color, ··· 689 803 use super::*; 690 804 use crate::cache_impl; 691 805 use gloo_worker::{HandlerId, Worker, WorkerScope}; 806 + use jacquard::IntoStatic; 692 807 use jacquard::client::UnauthenticatedSession; 693 808 use jacquard::identity::JacquardResolver; 694 809 use jacquard::prelude::*; 695 810 use jacquard::types::string::AtUri; 696 - use jacquard::IntoStatic; 697 811 use std::time::Duration; 698 812 699 813 /// Embed worker with persistent cache. ··· 731 845 let at_uri = match AtUri::new_owned(uri_str.clone()) { 732 846 Ok(u) => u, 733 847 Err(e) => { 734 - errors.insert(uri_str, format_smolstr!("Invalid AT URI: {e}").to_string()); 848 + errors.insert(uri_str, format!("Invalid AT URI: {e}")); 735 849 continue; 736 850 } 737 851 }; ··· 773 887 results.insert(uri_str, html); 774 888 } 775 889 Err(e) => { 776 - errors.insert(uri_str, format_smolstr!("{:?}", e).to_string()); 890 + errors.insert(uri_str, format!("{:?}", e)); 777 891 } 778 892 } 779 893 }
-91
crates/weaver-app/src/components/entry.rs
··· 23 23 use std::sync::Arc; 24 24 use weaver_api::sh_weaver::notebook::{BookEntryView, EntryView, entry}; 25 25 26 - // #[component] 27 - // pub fn EntryPage( 28 - // ident: ReadSignal<AtIdentifier<'static>>, 29 - // book_title: ReadSignal<SmolStr>, 30 - // title: ReadSignal<SmolStr>, 31 - // ) -> Element { 32 - // rsx! { 33 - // {std::iter::once(rsx! {Entry {ident, book_title, title}})} 34 - // } 35 - // } 36 - 37 26 #[component] 38 27 pub fn EntryPage( 39 28 ident: ReadSignal<AtIdentifier<'static>>, ··· 81 70 // Use read() instead of read_unchecked() for proper reactive tracking 82 71 match &*entry.read() { 83 72 Some((book_entry_view, entry_record)) => { 84 - if let Some(embeds) = &entry_record.embeds { 85 - if let Some(_images) = &embeds.images { 86 - // Register blob mappings with service worker (client-side only) 87 - // #[cfg(all( 88 - // target_family = "wasm", 89 - // target_os = "unknown", 90 - // not(feature = "fullstack-server") 91 - // ))] 92 - // { 93 - // let fetcher = fetcher.clone(); 94 - // let images = _images.clone().into_static(); 95 - // spawn(async move { 96 - // let images = images.clone(); 97 - // let fetcher = fetcher.clone(); 98 - // let _ = crate::service_worker::register_entry_blobs( 99 - // &ident(), 100 - // book_title().as_str(), 101 - // &_images, 102 - // &fetcher, 103 - // ) 104 - // .await; 105 - // }); 106 - // } 107 - } 108 - } 109 73 rsx! { EntryPageView { 110 74 book_entry_view: book_entry_view.clone(), 111 75 entry_record: entry_record.clone(), ··· 149 113 let truncated: String = cleaned.chars().take(max_len - 3).collect(); 150 114 format!("{}...", truncated) 151 115 } 152 - } 153 - 154 - /// Truncate markdown content for preview (preserves markdown syntax) 155 - /// Takes first few paragraphs up to max_chars, truncating at paragraph boundary 156 - fn truncate_markdown_preview(content: &str, max_chars: usize, max_paragraphs: usize) -> String { 157 - let mut result = String::new(); 158 - let mut char_count = 0; 159 - let mut para_count = 0; 160 - let mut in_code_block = false; 161 - 162 - for line in content.lines() { 163 - // Track code blocks to avoid breaking them 164 - if line.trim().starts_with("```") { 165 - in_code_block = !in_code_block; 166 - // Skip code blocks in preview entirely 167 - if in_code_block { 168 - continue; 169 - } 170 - } 171 - 172 - if in_code_block { 173 - continue; 174 - } 175 - 176 - // Skip headings, images in preview 177 - let trimmed = line.trim(); 178 - if trimmed.starts_with('#') || trimmed.starts_with('!') { 179 - continue; 180 - } 181 - 182 - // Empty line = paragraph boundary 183 - if trimmed.is_empty() { 184 - if !result.is_empty() && !result.ends_with("\n\n") { 185 - para_count += 1; 186 - if para_count >= max_paragraphs || char_count >= max_chars { 187 - break; 188 - } 189 - result.push_str("\n\n"); 190 - } 191 - continue; 192 - } 193 - 194 - // Check if adding this line would exceed limit 195 - if char_count + line.len() > max_chars && !result.is_empty() { 196 - break; 197 - } 198 - 199 - if !result.is_empty() && !result.ends_with('\n') { 200 - result.push('\n'); 201 - } 202 - result.push_str(line); 203 - char_count += line.len(); 204 - } 205 - 206 - result.trim().to_string() 207 116 } 208 117 209 118 /// OpenGraph and Twitter Card meta tags for entries
+117 -211
crates/weaver-app/src/components/identity.rs
··· 107 107 }); 108 108 109 109 // Extract pinned URIs from profile (only Weaver ProfileView has pinned) 110 + // Returns (Vec for ordering, HashSet for O(1) lookups) 110 111 let pinned_uris = use_memo(move || { 111 112 use jacquard::IntoStatic; 112 - use jacquard::types::aturi::AtUri; 113 113 use weaver_api::sh_weaver::actor::ProfileDataViewInner; 114 114 115 115 let Some(prof) = profile.read().as_ref().cloned() else { 116 - return Vec::<AtUri<'static>>::new(); 116 + return (Vec::<String>::new(), HashSet::<String>::new()); 117 117 }; 118 118 119 119 match &prof.inner { 120 - ProfileDataViewInner::ProfileView(p) => p 121 - .pinned 122 - .as_ref() 123 - .map(|pins| pins.iter().map(|r| r.uri.clone().into_static()).collect()) 124 - .unwrap_or_default(), 125 - _ => Vec::new(), 120 + ProfileDataViewInner::ProfileView(p) => { 121 + let uris: Vec<String> = p 122 + .pinned 123 + .as_ref() 124 + .map(|pins| pins.iter().map(|r| r.uri.as_ref().to_string()).collect()) 125 + .unwrap_or_default(); 126 + let set: HashSet<String> = uris.iter().cloned().collect(); 127 + (uris, set) 128 + } 129 + _ => (Vec::new(), HashSet::new()), 126 130 } 127 131 }); 128 132 ··· 149 153 }); 150 154 151 155 // Helper to check if a URI is pinned 152 - fn is_pinned(uri: &str, pinned: &[jacquard::types::aturi::AtUri<'static>]) -> bool { 153 - pinned.iter().any(|p| p.as_ref() == uri) 156 + fn is_pinned(uri: &str, pinned_set: &HashSet<String>) -> bool { 157 + pinned_set.contains(uri) 154 158 } 155 159 156 160 // Build pinned items (matching notebooks/entries against pinned URIs) ··· 158 162 let nbs = notebooks.read(); 159 163 let standalone = standalone_entries.read(); 160 164 let ents = all_entries.read(); 161 - let pinned = pinned_uris.read(); 165 + let (pinned_vec, pinned_set) = &*pinned_uris.read(); 162 166 163 167 let mut items: Vec<ProfileTimelineItem> = Vec::new(); 164 168 ··· 166 170 if let Some(nbs) = nbs.as_ref() { 167 171 if let Some(all_ents) = ents.as_ref() { 168 172 for (notebook, entry_refs) in nbs { 169 - if is_pinned(notebook.uri.as_ref(), &pinned) { 173 + if is_pinned(notebook.uri.as_ref(), pinned_set) { 170 174 let sort_date = entry_refs 171 175 .iter() 172 176 .filter_map(|r| { ··· 190 194 191 195 // Check standalone entries 192 196 for (view, entry) in standalone.iter() { 193 - if is_pinned(view.uri.as_ref(), &pinned) { 197 + if is_pinned(view.uri.as_ref(), pinned_set) { 194 198 items.push(ProfileTimelineItem::StandaloneEntry { 195 199 entry_view: view.clone(), 196 200 entry: entry.clone(), ··· 204 208 ProfileTimelineItem::Notebook { notebook, .. } => notebook.uri.as_ref(), 205 209 ProfileTimelineItem::StandaloneEntry { entry_view, .. } => entry_view.uri.as_ref(), 206 210 }; 207 - pinned 211 + pinned_vec 208 212 .iter() 209 - .position(|p| p.as_ref() == uri) 213 + .position(|p| p == uri) 210 214 .unwrap_or(usize::MAX) 211 215 }); 212 216 ··· 218 222 let nbs = notebooks.read(); 219 223 let standalone = standalone_entries.read(); 220 224 let ents = all_entries.read(); 221 - let pinned = pinned_uris.read(); 225 + let (_pinned_vec, pinned_set) = &*pinned_uris.read(); 222 226 223 227 let mut items: Vec<ProfileTimelineItem> = Vec::new(); 224 228 ··· 226 230 if let Some(nbs) = nbs.as_ref() { 227 231 if let Some(all_ents) = ents.as_ref() { 228 232 for (notebook, entry_refs) in nbs { 229 - if !is_pinned(notebook.uri.as_ref(), &pinned) { 233 + if !is_pinned(notebook.uri.as_ref(), pinned_set) { 230 234 let sort_date = entry_refs 231 235 .iter() 232 236 .filter_map(|r| { ··· 250 254 251 255 // Add standalone entries (excluding pinned) 252 256 for (view, entry) in standalone.iter() { 253 - if !is_pinned(view.uri.as_ref(), &pinned) { 257 + if !is_pinned(view.uri.as_ref(), pinned_set) { 254 258 items.push(ProfileTimelineItem::StandaloneEntry { 255 259 entry_view: view.clone(), 256 260 entry: entry.clone(), ··· 443 447 } 444 448 445 449 #[component] 450 + fn NotebookEntryPreview( 451 + book_entry_view: weaver_api::sh_weaver::notebook::BookEntryView<'static>, 452 + ident: AtIdentifier<'static>, 453 + book_title: SmolStr, 454 + #[props(default)] extra_class: Option<&'static str>, 455 + ) -> Element { 456 + use jacquard::{IntoStatic, from_data}; 457 + use weaver_api::sh_weaver::notebook::entry::Entry; 458 + 459 + let entry_view = &book_entry_view.entry; 460 + 461 + let entry_title = entry_view.title.as_ref() 462 + .map(|t| t.as_ref()) 463 + .unwrap_or("Untitled"); 464 + 465 + let entry_path = entry_view.path 466 + .as_ref() 467 + .map(|p| p.as_ref().to_string()) 468 + .unwrap_or_else(|| entry_title.to_string()); 469 + 470 + let parsed_entry = from_data::<Entry>(&entry_view.record).ok(); 471 + 472 + let preview_html = parsed_entry.as_ref().map(|entry| { 473 + let parser = markdown_weaver::Parser::new(&entry.content); 474 + let mut html_buf = String::new(); 475 + markdown_weaver::html::push_html(&mut html_buf, parser); 476 + html_buf 477 + }); 478 + 479 + let created_at = parsed_entry.as_ref() 480 + .map(|entry| entry.created_at.as_ref().format("%B %d, %Y").to_string()); 481 + 482 + let entry_uri = entry_view.uri.clone().into_static(); 483 + 484 + let class_name = if let Some(extra) = extra_class { 485 + format!("notebook-entry-preview {}", extra) 486 + } else { 487 + "notebook-entry-preview".to_string() 488 + }; 489 + 490 + rsx! { 491 + div { class: "{class_name}", 492 + div { class: "entry-preview-header", 493 + Link { 494 + to: Route::EntryPage { 495 + ident: ident.clone(), 496 + book_title: book_title.clone(), 497 + title: entry_path.clone().into() 498 + }, 499 + class: "entry-preview-title-link", 500 + div { class: "entry-preview-title", "{entry_title}" } 501 + } 502 + if let Some(ref date) = created_at { 503 + div { class: "entry-preview-date", "{date}" } 504 + } 505 + crate::components::EntryActions { 506 + entry_uri, 507 + entry_cid: entry_view.cid.clone().into_static(), 508 + entry_title: entry_title.to_string(), 509 + in_notebook: true, 510 + notebook_title: Some(book_title.clone()), 511 + permissions: entry_view.permissions.clone() 512 + } 513 + } 514 + if let Some(ref html) = preview_html { 515 + Link { 516 + to: Route::EntryPage { 517 + ident: ident.clone(), 518 + book_title: book_title.clone(), 519 + title: entry_path.clone().into() 520 + }, 521 + class: "entry-preview-content-link", 522 + div { class: "entry-preview-content", dangerous_inner_html: "{html}" } 523 + } 524 + } 525 + } 526 + } 527 + } 528 + 529 + #[component] 446 530 pub fn NotebookCard( 447 531 notebook: NotebookView<'static>, 448 532 entry_refs: Vec<StrongRef<'static>>, ··· 567 651 if entry_list.len() <= 5 { 568 652 // Show all entries if 5 or fewer 569 653 rsx! { 570 - for entry_view in entry_list.iter() { 571 - { 572 - let entry_title = entry_view.entry.title.as_ref() 573 - .map(|t| t.as_ref()) 574 - .unwrap_or("Untitled"); 575 - 576 - // Get path from view, fallback to title 577 - let entry_path = entry_view.entry.path 578 - .as_ref() 579 - .map(|p| p.as_ref().to_string()) 580 - .unwrap_or_else(|| entry_title.to_string()); 581 - 582 - // Parse entry for created_at and preview 583 - let parsed_entry = from_data::<Entry>(&entry_view.entry.record).ok(); 584 - 585 - let preview_html = parsed_entry.as_ref().map(|entry| { 586 - let parser = markdown_weaver::Parser::new(&entry.content); 587 - let mut html_buf = String::new(); 588 - markdown_weaver::html::push_html(&mut html_buf, parser); 589 - html_buf 590 - }); 591 - 592 - let created_at = parsed_entry.as_ref() 593 - .map(|entry| entry.created_at.as_ref().format("%B %d, %Y").to_string()); 594 - 595 - let entry_uri = entry_view.entry.uri.clone().into_static(); 596 - 597 - rsx! { 598 - div { class: "notebook-entry-preview", 599 - div { class: "entry-preview-header", 600 - Link { 601 - to: Route::EntryPage { 602 - ident: ident.clone(), 603 - book_title: book_title.clone(), 604 - title: entry_path.clone().into() 605 - }, 606 - class: "entry-preview-title-link", 607 - div { class: "entry-preview-title", "{entry_title}" } 608 - } 609 - if let Some(ref date) = created_at { 610 - div { class: "entry-preview-date", "{date}" } 611 - } 612 - // EntryActions handles visibility via permissions 613 - crate::components::EntryActions { 614 - entry_uri, 615 - entry_cid: entry_view.entry.cid.clone().into_static(), 616 - entry_title: entry_title.to_string(), 617 - in_notebook: true, 618 - notebook_title: Some(book_title.clone()), 619 - permissions: entry_view.entry.permissions.clone() 620 - } 621 - } 622 - if let Some(ref html) = preview_html { 623 - Link { 624 - to: Route::EntryPage { 625 - ident: ident.clone(), 626 - book_title: book_title.clone(), 627 - title: entry_path.clone().into() 628 - }, 629 - class: "entry-preview-content-link", 630 - div { class: "entry-preview-content", dangerous_inner_html: "{html}" } 631 - } 632 - } 633 - } 634 - } 654 + for entry_view in entry_list.iter() { 655 + NotebookEntryPreview { 656 + book_entry_view: entry_view.clone(), 657 + ident: ident.clone(), 658 + book_title: book_title.clone(), 635 659 } 636 660 } 637 661 } ··· 639 663 // Show first, interstitial, and last 640 664 rsx! { 641 665 if let Some(first_entry) = entry_list.first() { 642 - { 643 - let entry_title = first_entry.entry.title.as_ref() 644 - .map(|t| t.as_ref()) 645 - .unwrap_or("Untitled"); 646 - 647 - // Get path from view, fallback to title 648 - let entry_path = first_entry.entry.path 649 - .as_ref() 650 - .map(|p| p.as_ref().to_string()) 651 - .unwrap_or_else(|| entry_title.to_string()); 652 - 653 - // Parse entry for created_at and preview 654 - let parsed_entry = from_data::<Entry>(&first_entry.entry.record).ok(); 655 - 656 - let preview_html = parsed_entry.as_ref().map(|entry| { 657 - let parser = markdown_weaver::Parser::new(&entry.content); 658 - let mut html_buf = String::new(); 659 - markdown_weaver::html::push_html(&mut html_buf, parser); 660 - html_buf 661 - }); 662 - 663 - let created_at = parsed_entry.as_ref() 664 - .map(|entry| entry.created_at.as_ref().format("%B %d, %Y").to_string()); 665 - 666 - let entry_uri = first_entry.entry.uri.clone().into_static(); 667 - 668 - rsx! { 669 - div { class: "notebook-entry-preview notebook-entry-preview-first", 670 - div { class: "entry-preview-header", 671 - Link { 672 - to: Route::EntryPage { 673 - ident: ident.clone(), 674 - book_title: book_title.clone(), 675 - title: entry_path.clone().into() 676 - }, 677 - class: "entry-preview-title-link", 678 - div { class: "entry-preview-title", "{entry_title}" } 679 - } 680 - if let Some(ref date) = created_at { 681 - div { class: "entry-preview-date", "{date}" } 682 - } 683 - // EntryActions handles visibility via permissions 684 - crate::components::EntryActions { 685 - entry_uri, 686 - entry_cid: first_entry.entry.cid.clone().into_static(), 687 - entry_title: entry_title.to_string(), 688 - in_notebook: true, 689 - notebook_title: Some(book_title.clone()), 690 - permissions: first_entry.entry.permissions.clone() 691 - } 692 - } 693 - if let Some(ref html) = preview_html { 694 - Link { 695 - to: Route::EntryPage { 696 - ident: ident.clone(), 697 - book_title: book_title.clone(), 698 - title: entry_path.clone().into() 699 - }, 700 - class: "entry-preview-content-link", 701 - div { class: "entry-preview-content", dangerous_inner_html: "{html}" } 702 - } 703 - } 704 - } 705 - } 666 + NotebookEntryPreview { 667 + book_entry_view: first_entry.clone(), 668 + ident: ident.clone(), 669 + book_title: book_title.clone(), 670 + extra_class: "notebook-entry-preview-first", 706 671 } 707 672 } 708 673 ··· 719 684 } 720 685 721 686 if let Some(last_entry) = entry_list.last() { 722 - { 723 - let entry_title = last_entry.entry.title.as_ref() 724 - .map(|t| t.as_ref()) 725 - .unwrap_or("Untitled"); 726 - 727 - // Get path from view, fallback to title 728 - let entry_path = last_entry.entry.path 729 - .as_ref() 730 - .map(|p| p.as_ref().to_string()) 731 - .unwrap_or_else(|| entry_title.to_string()); 732 - 733 - // Parse entry for created_at and preview 734 - let parsed_entry = from_data::<Entry>(&last_entry.entry.record).ok(); 735 - 736 - let preview_html = parsed_entry.as_ref().map(|entry| { 737 - let parser = markdown_weaver::Parser::new(&entry.content); 738 - let mut html_buf = String::new(); 739 - markdown_weaver::html::push_html(&mut html_buf, parser); 740 - html_buf 741 - }); 742 - 743 - let created_at = parsed_entry.as_ref() 744 - .map(|entry| entry.created_at.as_ref().format("%B %d, %Y").to_string()); 745 - 746 - let entry_uri = last_entry.entry.uri.clone().into_static(); 747 - 748 - rsx! { 749 - div { class: "notebook-entry-preview notebook-entry-preview-last", 750 - div { class: "entry-preview-header", 751 - Link { 752 - to: Route::EntryPage { 753 - ident: ident.clone(), 754 - book_title: book_title.clone(), 755 - title: entry_path.clone().into() 756 - }, 757 - class: "entry-preview-title-link", 758 - div { class: "entry-preview-title", "{entry_title}" } 759 - } 760 - if let Some(ref date) = created_at { 761 - div { class: "entry-preview-date", "{date}" } 762 - } 763 - // EntryActions handles visibility via permissions 764 - crate::components::EntryActions { 765 - entry_uri, 766 - entry_cid: last_entry.entry.cid.clone().into_static(), 767 - entry_title: entry_title.to_string(), 768 - in_notebook: true, 769 - notebook_title: Some(book_title.clone()), 770 - permissions: last_entry.entry.permissions.clone() 771 - } 772 - } 773 - if let Some(ref html) = preview_html { 774 - Link { 775 - to: Route::EntryPage { 776 - ident: ident.clone(), 777 - book_title: book_title.clone(), 778 - title: entry_path.clone().into() 779 - }, 780 - class: "entry-preview-content-link", 781 - div { class: "entry-preview-content", dangerous_inner_html: "{html}" } 782 - } 783 - } 784 - } 785 - } 687 + NotebookEntryPreview { 688 + book_entry_view: last_entry.clone(), 689 + ident: ident.clone(), 690 + book_title: book_title.clone(), 691 + extra_class: "notebook-entry-preview-last", 786 692 } 787 693 } 788 694 }
-12
crates/weaver-app/src/data.rs
··· 906 906 .await 907 907 .ok(); 908 908 } 909 - #[cfg(all(target_family = "wasm", target_os = "unknown",))] 910 - { 911 - tracing::info!("Registering standalone entry blobs"); 912 - let _ = 913 - crate::service_worker::register_standalone_entry_blobs( 914 - &ident, 915 - at_uri.rkey().unwrap().0.as_str(), 916 - images, 917 - &fetcher, 918 - ) 919 - .await; 920 - } 921 909 } 922 910 } 923 911 }
+27 -28
crates/weaver-app/src/og/mod.rs
··· 5 5 6 6 use crate::cache_impl::{Cache, new_cache}; 7 7 use askama::Template; 8 - use jacquard::smol_str::SmolStr; 9 - use jacquard::smol_str::format_smolstr; 8 + use jacquard::smol_str::{SmolStr, ToSmolStr, format_smolstr}; 10 9 use std::sync::OnceLock; 11 10 use std::time::Duration; 12 11 ··· 40 39 #[derive(Debug)] 41 40 pub enum OgError { 42 41 NotFound, 43 - FetchError(String), 44 - RenderError(String), 45 - TemplateError(String), 42 + FetchError(SmolStr), 43 + RenderError(SmolStr), 44 + TemplateError(SmolStr), 46 45 } 47 46 48 47 impl std::fmt::Display for OgError { ··· 77 76 pub struct TextOnlyTemplate { 78 77 pub title_lines: Vec<String>, 79 78 pub content_lines: Vec<String>, 80 - pub notebook_title: String, 81 - pub author_handle: String, 79 + pub notebook_title: SmolStr, 80 + pub author_handle: SmolStr, 82 81 } 83 82 84 83 /// Hero image template (full-bleed image with overlay) ··· 87 86 pub struct HeroImageTemplate { 88 87 pub hero_image_data: String, 89 88 pub title_lines: Vec<String>, 90 - pub notebook_title: String, 91 - pub author_handle: String, 89 + pub notebook_title: SmolStr, 90 + pub author_handle: SmolStr, 92 91 } 93 92 94 93 /// Notebook index template ··· 96 95 #[template(path = "og_notebook.svg", escape = "none")] 97 96 pub struct NotebookTemplate { 98 97 pub title_lines: Vec<String>, 99 - pub author_handle: String, 98 + pub author_handle: SmolStr, 100 99 pub entry_count: usize, 101 100 pub entry_titles: Vec<String>, 102 101 } ··· 107 106 pub struct ProfileTemplate { 108 107 pub avatar_data: Option<String>, 109 108 pub display_name_lines: Vec<String>, 110 - pub handle: String, 109 + pub handle: SmolStr, 111 110 pub bio_lines: Vec<String>, 112 111 pub notebook_count: usize, 113 112 } ··· 119 118 pub banner_image_data: String, 120 119 pub avatar_data: Option<String>, 121 120 pub display_name_lines: Vec<String>, 122 - pub handle: String, 121 + pub handle: SmolStr, 123 122 pub bio_lines: Vec<String>, 124 123 pub notebook_count: usize, 125 124 } ··· 166 165 }; 167 166 168 167 let tree = usvg::Tree::from_str(svg, &options) 169 - .map_err(|e| OgError::RenderError(format!("Failed to parse SVG: {}", e)))?; 168 + .map_err(|e| OgError::RenderError(format_smolstr!("Failed to parse SVG: {}", e)))?; 170 169 171 170 let mut pixmap = tiny_skia::Pixmap::new(OG_WIDTH, OG_HEIGHT) 172 - .ok_or_else(|| OgError::RenderError("Failed to create pixmap".to_string()))?; 171 + .ok_or_else(|| OgError::RenderError("Failed to create pixmap".to_smolstr()))?; 173 172 174 173 resvg::render(&tree, tiny_skia::Transform::default(), &mut pixmap.as_mut()); 175 174 176 175 pixmap 177 176 .encode_png() 178 - .map_err(|e| OgError::RenderError(format!("Failed to encode PNG: {}", e))) 177 + .map_err(|e| OgError::RenderError(format_smolstr!("Failed to encode PNG: {}", e))) 179 178 } 180 179 181 180 /// Generate a text-only OG image ··· 191 190 let template = TextOnlyTemplate { 192 191 title_lines, 193 192 content_lines, 194 - notebook_title: notebook_title.to_string(), 195 - author_handle: author_handle.to_string(), 193 + notebook_title: notebook_title.to_smolstr(), 194 + author_handle: author_handle.to_smolstr(), 196 195 }; 197 196 198 197 let svg = template 199 198 .render() 200 - .map_err(|e| OgError::TemplateError(e.to_string()))?; 199 + .map_err(|e| OgError::TemplateError(e.to_smolstr()))?; 201 200 202 201 render_svg_to_png(&svg) 203 202 } ··· 214 213 let template = HeroImageTemplate { 215 214 hero_image_data: hero_image_data.to_string(), 216 215 title_lines, 217 - notebook_title: notebook_title.to_string(), 218 - author_handle: author_handle.to_string(), 216 + notebook_title: notebook_title.to_smolstr(), 217 + author_handle: author_handle.to_smolstr(), 219 218 }; 220 219 221 220 let svg = template 222 221 .render() 223 - .map_err(|e| OgError::TemplateError(e.to_string()))?; 222 + .map_err(|e| OgError::TemplateError(e.to_smolstr()))?; 224 223 225 224 render_svg_to_png(&svg) 226 225 } ··· 258 257 259 258 let template = NotebookTemplate { 260 259 title_lines, 261 - author_handle: author_handle.to_string(), 260 + author_handle: author_handle.to_smolstr(), 262 261 entry_count, 263 262 entry_titles, 264 263 }; 265 264 266 265 let svg = template 267 266 .render() 268 - .map_err(|e| OgError::TemplateError(e.to_string()))?; 267 + .map_err(|e| OgError::TemplateError(e.to_smolstr()))?; 269 268 270 269 render_svg_to_png(&svg) 271 270 } ··· 284 283 let template = ProfileTemplate { 285 284 avatar_data, 286 285 display_name_lines, 287 - handle: handle.to_string(), 286 + handle: handle.to_smolstr(), 288 287 bio_lines, 289 288 notebook_count, 290 289 }; 291 290 292 291 let svg = template 293 292 .render() 294 - .map_err(|e| OgError::TemplateError(e.to_string()))?; 293 + .map_err(|e| OgError::TemplateError(e.to_smolstr()))?; 295 294 296 295 render_svg_to_png(&svg) 297 296 } ··· 312 311 banner_image_data, 313 312 avatar_data, 314 313 display_name_lines, 315 - handle: handle.to_string(), 314 + handle: handle.to_smolstr(), 316 315 bio_lines, 317 316 notebook_count, 318 317 }; 319 318 320 319 let svg = template 321 320 .render() 322 - .map_err(|e| OgError::TemplateError(e.to_string()))?; 321 + .map_err(|e| OgError::TemplateError(e.to_smolstr()))?; 323 322 324 323 render_svg_to_png(&svg) 325 324 } ··· 330 329 331 330 let svg = template 332 331 .render() 333 - .map_err(|e| OgError::TemplateError(e.to_string()))?; 332 + .map_err(|e| OgError::TemplateError(e.to_smolstr()))?; 334 333 335 334 render_svg_to_png(&svg) 336 335 }
+5 -4
crates/weaver-common/src/agent.rs
··· 16 16 use jacquard::types::string::{AtUri, Did, RecordKey, Rkey}; 17 17 use jacquard::types::tid::Tid; 18 18 use jacquard::types::uri::Uri; 19 + use jacquard::smol_str::SmolStr; 19 20 use jacquard::url::Url; 20 21 use jacquard::{CowStr, IntoStatic}; 21 22 use mime_sniffer::MimeTypeSniffer; ··· 2180 2181 2181 2182 peers.push(SessionPeer { 2182 2183 did: record_id.did.into_static(), 2183 - node_id: session_record.value.node_id.to_string(), 2184 + node_id: session_record.value.node_id.as_ref().into(), 2184 2185 relay_url: session_record 2185 2186 .value 2186 2187 .relay_url 2187 - .map(|u| u.as_str().to_string()), 2188 + .map(|u| u.as_ref().into()), 2188 2189 created_at: session_record.value.created_at, 2189 2190 expires_at: session_record.value.expires_at, 2190 2191 }); ··· 2301 2302 /// The peer's DID. 2302 2303 pub did: Did<'a>, 2303 2304 /// The peer's iroh NodeId (z-base32 encoded). 2304 - pub node_id: String, 2305 + pub node_id: SmolStr, 2305 2306 /// Optional relay URL for browser clients. 2306 - pub relay_url: Option<String>, 2307 + pub relay_url: Option<SmolStr>, 2307 2308 /// When the session was created. 2308 2309 pub created_at: jacquard::types::string::Datetime, 2309 2310 /// When the session expires (if set).
+6 -5
crates/weaver-common/src/transport/messages.rs
··· 1 1 //! Wire protocol for collaborative editing messages. 2 2 3 + use jacquard::smol_str::SmolStr; 3 4 use serde::{Deserialize, Serialize}; 4 5 5 6 /// Messages exchanged between collaborators over gossip. ··· 26 27 /// Collaborator joined the session 27 28 Join { 28 29 /// DID of the joining user 29 - did: String, 30 + did: SmolStr, 30 31 /// Display name for presence UI 31 - display_name: String, 32 + display_name: SmolStr, 32 33 }, 33 34 34 35 /// Collaborator left the session 35 36 Leave { 36 37 /// DID of the leaving user 37 - did: String, 38 + did: SmolStr, 38 39 }, 39 40 40 41 /// Request sync from peers (late joiner) ··· 183 184 #[test] 184 185 fn test_roundtrip_join() { 185 186 let msg = CollabMessage::Join { 186 - did: "did:plc:abc123".to_string(), 187 - display_name: "Alice".to_string(), 187 + did: "did:plc:abc123".into(), 188 + display_name: "Alice".into(), 188 189 }; 189 190 let bytes = msg.to_bytes().unwrap(); 190 191 let decoded = CollabMessage::from_bytes(&bytes).unwrap();
+6 -5
crates/weaver-common/src/transport/node.rs
··· 6 6 use iroh::EndpointId; 7 7 use iroh::SecretKey; 8 8 use iroh_gossip::net::{GOSSIP_ALPN, Gossip}; 9 + use jacquard::smol_str::{SmolStr, ToSmolStr}; 9 10 use miette::Diagnostic; 10 11 use std::sync::Arc; 11 12 ··· 80 81 } 81 82 82 83 /// Get the node ID as a z-base32 string for storage in AT Protocol records. 83 - pub fn node_id_string(&self) -> String { 84 - self.endpoint.id().to_string() 84 + pub fn node_id_string(&self) -> SmolStr { 85 + self.endpoint.id().to_smolstr() 85 86 } 86 87 87 88 /// Get a reference to the gossip handler for joining topics. ··· 103 104 /// 104 105 /// This should be published in session records so other peers can connect 105 106 /// via relay (essential for browser-to-browser connections). 106 - pub fn relay_url(&self) -> Option<String> { 107 + pub fn relay_url(&self) -> Option<SmolStr> { 107 108 self.endpoint 108 109 .addr() 109 110 .relay_urls() 110 111 .next() 111 - .map(|url| url.to_string()) 112 + .map(|url| url.to_smolstr()) 112 113 } 113 114 114 115 /// Get the full node address including relay info. ··· 131 132 /// 132 133 /// Waits indefinitely for relay - browser clients require relay URLs 133 134 /// for peer discovery. Returns the relay URL once connected. 134 - pub async fn wait_for_relay(&self) -> String { 135 + pub async fn wait_for_relay(&self) -> SmolStr { 135 136 self.endpoint.online().await; 136 137 // After online(), relay_url should always be Some for browser clients 137 138 self.relay_url()
+11 -5
crates/weaver-common/src/transport/presence.rs
··· 7 7 use std::collections::HashMap; 8 8 9 9 use iroh::EndpointId; 10 + use jacquard::smol_str::SmolStr; 10 11 use web_time::Instant; 11 12 12 13 /// A remote collaborator's cursor state. ··· 26 27 #[derive(Debug, Clone)] 27 28 pub struct Collaborator { 28 29 /// The collaborator's DID. 29 - pub did: String, 30 + pub did: SmolStr, 30 31 /// Display name for UI. 31 - pub display_name: String, 32 + pub display_name: SmolStr, 32 33 /// Assigned colour (RGBA). 33 34 pub color: u32, 34 35 /// Current cursor state. ··· 65 66 } 66 67 67 68 /// Add a collaborator when they join. 68 - pub fn add_collaborator(&mut self, node_id: EndpointId, did: String, display_name: String) { 69 + pub fn add_collaborator( 70 + &mut self, 71 + node_id: EndpointId, 72 + did: impl Into<SmolStr>, 73 + display_name: impl Into<SmolStr>, 74 + ) { 69 75 let color = self.assign_color(); 70 76 self.collaborators.insert( 71 77 node_id, 72 78 Collaborator { 73 - did, 74 - display_name, 79 + did: did.into(), 80 + display_name: display_name.into(), 75 81 color, 76 82 cursor: None, 77 83 node_id,
+6 -5
crates/weaver-common/src/transport/presence_types.rs
··· 1 1 //! Presence types for main thread rendering. 2 2 //! 3 - //! These types use String node IDs instead of EndpointId, 3 + //! These types use SmolStr node IDs instead of EndpointId, 4 4 //! allowing them to be used without the iroh feature. 5 5 6 + use jacquard::smol_str::SmolStr; 6 7 use serde::{Deserialize, Serialize}; 7 8 8 9 /// A remote collaborator's cursor for rendering. 9 10 #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] 10 11 pub struct RemoteCursorInfo { 11 12 /// Node ID as string (z-base32 encoded) 12 - pub node_id: String, 13 + pub node_id: SmolStr, 13 14 /// Character offset in the document 14 15 pub position: usize, 15 16 /// Selection range (anchor, head) if any ··· 22 23 #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] 23 24 pub struct CollaboratorInfo { 24 25 /// Node ID as string 25 - pub node_id: String, 26 + pub node_id: SmolStr, 26 27 /// The collaborator's DID 27 - pub did: String, 28 + pub did: SmolStr, 28 29 /// Display name for UI 29 - pub display_name: String, 30 + pub display_name: SmolStr, 30 31 /// Assigned colour (RGBA) 31 32 pub color: u32, 32 33 /// Current cursor position (if known)