sync works.

Orual 0c330aaa 29177f7f

+729 -160
+6 -1
crates/weaver-api/lexicons/sh_weaver_edit_diff.json
··· 9 "record": { 10 "type": "object", 11 "required": [ 12 - "snapshot", 13 "root", 14 "doc" 15 ], ··· 18 "type": "ref", 19 "ref": "sh.weaver.edit.defs#docRef" 20 }, 21 "prev": { 22 "type": "ref", 23 "ref": "com.atproto.repo.strongRef" ··· 28 }, 29 "snapshot": { 30 "type": "blob", 31 "accept": [ 32 "*/*" 33 ],
··· 9 "record": { 10 "type": "object", 11 "required": [ 12 "root", 13 "doc" 14 ], ··· 17 "type": "ref", 18 "ref": "sh.weaver.edit.defs#docRef" 19 }, 20 + "inlineDiff": { 21 + "type": "bytes", 22 + "description": "An inline diff for for small edit batches. Either this or snapshot must be present to be valid", 23 + "maxLength": 8192 24 + }, 25 "prev": { 26 "type": "ref", 27 "ref": "com.atproto.repo.strongRef" ··· 32 }, 33 "snapshot": { 34 "type": "blob", 35 + "description": "Diff from previous diff. Either this or inlineDiff must be present to be valid", 36 "accept": [ 37 "*/*" 38 ],
+56 -41
crates/weaver-api/src/sh_weaver/edit/diff.rs
··· 20 pub struct Diff<'a> { 21 #[serde(borrow)] 22 pub doc: crate::sh_weaver::edit::DocRef<'a>, 23 #[serde(skip_serializing_if = "std::option::Option::is_none")] 24 #[serde(borrow)] 25 pub prev: std::option::Option<crate::com_atproto::repo::strong_ref::StrongRef<'a>>, 26 #[serde(borrow)] 27 pub root: crate::com_atproto::repo::strong_ref::StrongRef<'a>, 28 #[serde(borrow)] 29 - pub snapshot: jacquard_common::types::blob::BlobRef<'a>, 30 } 31 32 pub mod diff_state { ··· 39 } 40 /// State trait tracking which required fields have been set 41 pub trait State: sealed::Sealed { 42 - type Snapshot; 43 type Root; 44 type Doc; 45 } ··· 47 pub struct Empty(()); 48 impl sealed::Sealed for Empty {} 49 impl State for Empty { 50 - type Snapshot = Unset; 51 type Root = Unset; 52 type Doc = Unset; 53 } 54 - ///State transition - sets the `snapshot` field to Set 55 - pub struct SetSnapshot<S: State = Empty>(PhantomData<fn() -> S>); 56 - impl<S: State> sealed::Sealed for SetSnapshot<S> {} 57 - impl<S: State> State for SetSnapshot<S> { 58 - type Snapshot = Set<members::snapshot>; 59 - type Root = S::Root; 60 - type Doc = S::Doc; 61 - } 62 ///State transition - sets the `root` field to Set 63 pub struct SetRoot<S: State = Empty>(PhantomData<fn() -> S>); 64 impl<S: State> sealed::Sealed for SetRoot<S> {} 65 impl<S: State> State for SetRoot<S> { 66 - type Snapshot = S::Snapshot; 67 type Root = Set<members::root>; 68 type Doc = S::Doc; 69 } ··· 71 pub struct SetDoc<S: State = Empty>(PhantomData<fn() -> S>); 72 impl<S: State> sealed::Sealed for SetDoc<S> {} 73 impl<S: State> State for SetDoc<S> { 74 - type Snapshot = S::Snapshot; 75 type Root = S::Root; 76 type Doc = Set<members::doc>; 77 } 78 /// Marker types for field names 79 #[allow(non_camel_case_types)] 80 pub mod members { 81 - ///Marker type for the `snapshot` field 82 - pub struct snapshot(()); 83 ///Marker type for the `root` field 84 pub struct root(()); 85 ///Marker type for the `doc` field ··· 92 _phantom_state: ::core::marker::PhantomData<fn() -> S>, 93 __unsafe_private_named: ( 94 ::core::option::Option<crate::sh_weaver::edit::DocRef<'a>>, 95 ::core::option::Option<crate::com_atproto::repo::strong_ref::StrongRef<'a>>, 96 ::core::option::Option<crate::com_atproto::repo::strong_ref::StrongRef<'a>>, 97 ::core::option::Option<jacquard_common::types::blob::BlobRef<'a>>, ··· 111 pub fn new() -> Self { 112 DiffBuilder { 113 _phantom_state: ::core::marker::PhantomData, 114 - __unsafe_private_named: (None, None, None, None), 115 _phantom: ::core::marker::PhantomData, 116 } 117 } ··· 137 } 138 139 impl<'a, S: diff_state::State> DiffBuilder<'a, S> { 140 /// Set the `prev` field (optional) 141 pub fn prev( 142 mut self, 143 value: impl Into<Option<crate::com_atproto::repo::strong_ref::StrongRef<'a>>>, 144 ) -> Self { 145 - self.__unsafe_private_named.1 = value.into(); 146 self 147 } 148 /// Set the `prev` field to an Option value (optional) ··· 150 mut self, 151 value: Option<crate::com_atproto::repo::strong_ref::StrongRef<'a>>, 152 ) -> Self { 153 - self.__unsafe_private_named.1 = value; 154 self 155 } 156 } ··· 165 mut self, 166 value: impl Into<crate::com_atproto::repo::strong_ref::StrongRef<'a>>, 167 ) -> DiffBuilder<'a, diff_state::SetRoot<S>> { 168 - self.__unsafe_private_named.2 = ::core::option::Option::Some(value.into()); 169 DiffBuilder { 170 _phantom_state: ::core::marker::PhantomData, 171 __unsafe_private_named: self.__unsafe_private_named, ··· 174 } 175 } 176 177 - impl<'a, S> DiffBuilder<'a, S> 178 - where 179 - S: diff_state::State, 180 - S::Snapshot: diff_state::IsUnset, 181 - { 182 - /// Set the `snapshot` field (required) 183 pub fn snapshot( 184 mut self, 185 - value: impl Into<jacquard_common::types::blob::BlobRef<'a>>, 186 - ) -> DiffBuilder<'a, diff_state::SetSnapshot<S>> { 187 - self.__unsafe_private_named.3 = ::core::option::Option::Some(value.into()); 188 - DiffBuilder { 189 - _phantom_state: ::core::marker::PhantomData, 190 - __unsafe_private_named: self.__unsafe_private_named, 191 - _phantom: ::core::marker::PhantomData, 192 - } 193 } 194 } 195 196 impl<'a, S> DiffBuilder<'a, S> 197 where 198 S: diff_state::State, 199 - S::Snapshot: diff_state::IsSet, 200 S::Root: diff_state::IsSet, 201 S::Doc: diff_state::IsSet, 202 { ··· 204 pub fn build(self) -> Diff<'a> { 205 Diff { 206 doc: self.__unsafe_private_named.0.unwrap(), 207 - prev: self.__unsafe_private_named.1, 208 - root: self.__unsafe_private_named.2.unwrap(), 209 - snapshot: self.__unsafe_private_named.3.unwrap(), 210 extra_data: Default::default(), 211 } 212 } ··· 220 ) -> Diff<'a> { 221 Diff { 222 doc: self.__unsafe_private_named.0.unwrap(), 223 - prev: self.__unsafe_private_named.1, 224 - root: self.__unsafe_private_named.2.unwrap(), 225 - snapshot: self.__unsafe_private_named.3.unwrap(), 226 extra_data: Some(extra_data), 227 } 228 } ··· 329 description: None, 330 required: Some( 331 vec![ 332 - ::jacquard_common::smol_str::SmolStr::new_static("snapshot"), 333 ::jacquard_common::smol_str::SmolStr::new_static("root"), 334 ::jacquard_common::smol_str::SmolStr::new_static("doc") 335 ], ··· 345 r#ref: ::jacquard_common::CowStr::new_static( 346 "sh.weaver.edit.defs#docRef", 347 ), 348 }), 349 ); 350 map.insert(
··· 20 pub struct Diff<'a> { 21 #[serde(borrow)] 22 pub doc: crate::sh_weaver::edit::DocRef<'a>, 23 + /// An inline diff for for small edit batches. Either this or snapshot must be present to be valid 24 + #[serde(skip_serializing_if = "std::option::Option::is_none")] 25 + pub inline_diff: std::option::Option<bytes::Bytes>, 26 #[serde(skip_serializing_if = "std::option::Option::is_none")] 27 #[serde(borrow)] 28 pub prev: std::option::Option<crate::com_atproto::repo::strong_ref::StrongRef<'a>>, 29 #[serde(borrow)] 30 pub root: crate::com_atproto::repo::strong_ref::StrongRef<'a>, 31 + /// Diff from previous diff. Either this or inlineDiff must be present to be valid 32 + #[serde(skip_serializing_if = "std::option::Option::is_none")] 33 #[serde(borrow)] 34 + pub snapshot: std::option::Option<jacquard_common::types::blob::BlobRef<'a>>, 35 } 36 37 pub mod diff_state { ··· 44 } 45 /// State trait tracking which required fields have been set 46 pub trait State: sealed::Sealed { 47 type Root; 48 type Doc; 49 } ··· 51 pub struct Empty(()); 52 impl sealed::Sealed for Empty {} 53 impl State for Empty { 54 type Root = Unset; 55 type Doc = Unset; 56 } 57 ///State transition - sets the `root` field to Set 58 pub struct SetRoot<S: State = Empty>(PhantomData<fn() -> S>); 59 impl<S: State> sealed::Sealed for SetRoot<S> {} 60 impl<S: State> State for SetRoot<S> { 61 type Root = Set<members::root>; 62 type Doc = S::Doc; 63 } ··· 65 pub struct SetDoc<S: State = Empty>(PhantomData<fn() -> S>); 66 impl<S: State> sealed::Sealed for SetDoc<S> {} 67 impl<S: State> State for SetDoc<S> { 68 type Root = S::Root; 69 type Doc = Set<members::doc>; 70 } 71 /// Marker types for field names 72 #[allow(non_camel_case_types)] 73 pub mod members { 74 ///Marker type for the `root` field 75 pub struct root(()); 76 ///Marker type for the `doc` field ··· 83 _phantom_state: ::core::marker::PhantomData<fn() -> S>, 84 __unsafe_private_named: ( 85 ::core::option::Option<crate::sh_weaver::edit::DocRef<'a>>, 86 + ::core::option::Option<bytes::Bytes>, 87 ::core::option::Option<crate::com_atproto::repo::strong_ref::StrongRef<'a>>, 88 ::core::option::Option<crate::com_atproto::repo::strong_ref::StrongRef<'a>>, 89 ::core::option::Option<jacquard_common::types::blob::BlobRef<'a>>, ··· 103 pub fn new() -> Self { 104 DiffBuilder { 105 _phantom_state: ::core::marker::PhantomData, 106 + __unsafe_private_named: (None, None, None, None, None), 107 _phantom: ::core::marker::PhantomData, 108 } 109 } ··· 129 } 130 131 impl<'a, S: diff_state::State> DiffBuilder<'a, S> { 132 + /// Set the `inlineDiff` field (optional) 133 + pub fn inline_diff(mut self, value: impl Into<Option<bytes::Bytes>>) -> Self { 134 + self.__unsafe_private_named.1 = value.into(); 135 + self 136 + } 137 + /// Set the `inlineDiff` field to an Option value (optional) 138 + pub fn maybe_inline_diff(mut self, value: Option<bytes::Bytes>) -> Self { 139 + self.__unsafe_private_named.1 = value; 140 + self 141 + } 142 + } 143 + 144 + impl<'a, S: diff_state::State> DiffBuilder<'a, S> { 145 /// Set the `prev` field (optional) 146 pub fn prev( 147 mut self, 148 value: impl Into<Option<crate::com_atproto::repo::strong_ref::StrongRef<'a>>>, 149 ) -> Self { 150 + self.__unsafe_private_named.2 = value.into(); 151 self 152 } 153 /// Set the `prev` field to an Option value (optional) ··· 155 mut self, 156 value: Option<crate::com_atproto::repo::strong_ref::StrongRef<'a>>, 157 ) -> Self { 158 + self.__unsafe_private_named.2 = value; 159 self 160 } 161 } ··· 170 mut self, 171 value: impl Into<crate::com_atproto::repo::strong_ref::StrongRef<'a>>, 172 ) -> DiffBuilder<'a, diff_state::SetRoot<S>> { 173 + self.__unsafe_private_named.3 = ::core::option::Option::Some(value.into()); 174 DiffBuilder { 175 _phantom_state: ::core::marker::PhantomData, 176 __unsafe_private_named: self.__unsafe_private_named, ··· 179 } 180 } 181 182 + impl<'a, S: diff_state::State> DiffBuilder<'a, S> { 183 + /// Set the `snapshot` field (optional) 184 pub fn snapshot( 185 mut self, 186 + value: impl Into<Option<jacquard_common::types::blob::BlobRef<'a>>>, 187 + ) -> Self { 188 + self.__unsafe_private_named.4 = value.into(); 189 + self 190 + } 191 + /// Set the `snapshot` field to an Option value (optional) 192 + pub fn maybe_snapshot( 193 + mut self, 194 + value: Option<jacquard_common::types::blob::BlobRef<'a>>, 195 + ) -> Self { 196 + self.__unsafe_private_named.4 = value; 197 + self 198 } 199 } 200 201 impl<'a, S> DiffBuilder<'a, S> 202 where 203 S: diff_state::State, 204 S::Root: diff_state::IsSet, 205 S::Doc: diff_state::IsSet, 206 { ··· 208 pub fn build(self) -> Diff<'a> { 209 Diff { 210 doc: self.__unsafe_private_named.0.unwrap(), 211 + inline_diff: self.__unsafe_private_named.1, 212 + prev: self.__unsafe_private_named.2, 213 + root: self.__unsafe_private_named.3.unwrap(), 214 + snapshot: self.__unsafe_private_named.4, 215 extra_data: Default::default(), 216 } 217 } ··· 225 ) -> Diff<'a> { 226 Diff { 227 doc: self.__unsafe_private_named.0.unwrap(), 228 + inline_diff: self.__unsafe_private_named.1, 229 + prev: self.__unsafe_private_named.2, 230 + root: self.__unsafe_private_named.3.unwrap(), 231 + snapshot: self.__unsafe_private_named.4, 232 extra_data: Some(extra_data), 233 } 234 } ··· 335 description: None, 336 required: Some( 337 vec![ 338 ::jacquard_common::smol_str::SmolStr::new_static("root"), 339 ::jacquard_common::smol_str::SmolStr::new_static("doc") 340 ], ··· 350 r#ref: ::jacquard_common::CowStr::new_static( 351 "sh.weaver.edit.defs#docRef", 352 ), 353 + }), 354 + ); 355 + map.insert( 356 + ::jacquard_common::smol_str::SmolStr::new_static( 357 + "inlineDiff", 358 + ), 359 + ::jacquard_lexicon::lexicon::LexObjectProperty::Bytes(::jacquard_lexicon::lexicon::LexBytes { 360 + description: None, 361 + max_length: Some(8192usize), 362 + min_length: None, 363 }), 364 ); 365 map.insert(
+81 -2
crates/weaver-app/assets/styling/editor.css
··· 389 } 390 391 /* Publish button and dialog - matches report dialog theming */ 392 .publish-button { 393 padding: 0.5rem 1rem; 394 background: var(--color-primary); ··· 398 cursor: pointer; 399 font-weight: 500; 400 font-family: var(--font-body); 401 - margin-left: auto; 402 - flex-shrink: 0; 403 } 404 405 .publish-button:hover:not(:disabled) { ··· 613 justify-content: flex-end; 614 margin-top: 1rem; 615 }
··· 389 } 390 391 /* Publish button and dialog - matches report dialog theming */ 392 + /* Actions container for sync status + publish button */ 393 + .meta-actions { 394 + display: flex; 395 + align-items: center; 396 + gap: 12px; 397 + margin-left: auto; 398 + flex-shrink: 0; 399 + } 400 + 401 .publish-button { 402 padding: 0.5rem 1rem; 403 background: var(--color-primary); ··· 407 cursor: pointer; 408 font-weight: 500; 409 font-family: var(--font-body); 410 } 411 412 .publish-button:hover:not(:disabled) { ··· 620 justify-content: flex-end; 621 margin-top: 1rem; 622 } 623 + 624 + /* Sync status indicator */ 625 + .sync-status { 626 + display: inline-flex; 627 + align-items: center; 628 + gap: 6px; 629 + padding: 4px 10px; 630 + border-radius: 12px; 631 + font-size: 12px; 632 + cursor: pointer; 633 + transition: background 0.2s ease, opacity 0.2s ease; 634 + user-select: none; 635 + } 636 + 637 + .sync-status:hover { 638 + opacity: 0.8; 639 + } 640 + 641 + .sync-status .sync-icon { 642 + font-size: 10px; 643 + line-height: 1; 644 + } 645 + 646 + .sync-status .sync-label { 647 + font-weight: 500; 648 + } 649 + 650 + /* Synced state - subtle success */ 651 + .sync-status.synced { 652 + background: color-mix(in srgb, var(--color-success) 15%, transparent); 653 + color: var(--color-success); 654 + } 655 + 656 + /* Syncing state - in progress */ 657 + .sync-status.syncing { 658 + background: color-mix(in srgb, var(--color-warning) 15%, transparent); 659 + color: var(--color-warning); 660 + cursor: wait; 661 + } 662 + 663 + .sync-status.syncing .sync-icon { 664 + animation: spin 1s linear infinite; 665 + } 666 + 667 + @keyframes spin { 668 + from { transform: rotate(0deg); } 669 + to { transform: rotate(360deg); } 670 + } 671 + 672 + /* Unsynced state - has pending changes */ 673 + .sync-status.unsynced { 674 + background: color-mix(in srgb, var(--color-warning) 20%, transparent); 675 + color: var(--color-warning); 676 + } 677 + 678 + /* Error state */ 679 + .sync-status.error { 680 + background: color-mix(in srgb, var(--color-error) 15%, transparent); 681 + color: var(--color-error); 682 + } 683 + 684 + /* Disabled state */ 685 + .sync-status.disabled { 686 + background: var(--color-overlay); 687 + color: var(--color-muted); 688 + cursor: default; 689 + opacity: 0.6; 690 + } 691 + 692 + .sync-status.disabled:hover { 693 + opacity: 0.6; 694 + }
+122 -63
crates/weaver-app/src/components/editor/component.rs
··· 23 use super::offset_map::SnapDirection; 24 use super::paragraph::ParagraphRender; 25 use super::platform; 26 use super::publish::{LoadedEntry, PublishButton, load_entry_for_editing}; 27 use super::render; 28 use super::storage; 29 use super::toolbar::EditorToolbar; 30 use super::visibility::update_syntax_visibility; 31 use super::writer::{EditorImageResolver, SyntaxSpanInfo}; 32 33 - /// Result of loading an entry - either loaded, failed, or not needed. 34 - #[derive(Clone, PartialEq)] 35 enum LoadResult { 36 - Loaded(LoadedEntry), 37 - Failed, 38 - NotNeeded, 39 } 40 41 - /// Wrapper component that handles loading an existing entry before rendering the editor. 42 /// 43 /// # Props 44 /// - `initial_content`: Optional initial markdown content (for new entries) ··· 47 pub fn MarkdownEditor(initial_content: Option<String>, entry_uri: Option<String>) -> Element { 48 let fetcher = use_context::<Fetcher>(); 49 50 - // Determine draft key - use entry URI if editing existing, otherwise "current" 51 - let draft_key = entry_uri.clone().unwrap_or_else(|| "current".to_string()); 52 53 - // Check if we have a local draft first 54 - let has_local_draft = use_hook(|| storage::load_from_storage(&draft_key).is_some()); 55 56 - // If we have an entry_uri but no local draft, we need to fetch from PDS 57 - let needs_fetch = entry_uri.is_some() && !has_local_draft; 58 59 - // Resource returns the load result 60 - let entry_resource = use_resource(move || { 61 let fetcher = fetcher.clone(); 62 - let uri_str = entry_uri.clone(); 63 async move { 64 - if !needs_fetch { 65 - return LoadResult::NotNeeded; 66 - } 67 - if let Some(uri_str) = uri_str { 68 - if let Ok(uri) = jacquard::types::string::AtUri::new(&uri_str) { 69 - match load_entry_for_editing(&fetcher, &uri).await { 70 - Ok(loaded) => return LoadResult::Loaded(loaded), 71 - Err(e) => { 72 - tracing::error!("Failed to load entry: {}", e); 73 - return LoadResult::Failed; 74 } 75 } 76 } 77 } 78 - LoadResult::Failed 79 } 80 }); 81 82 - // Render based on resource state 83 - match &*entry_resource.read() { 84 - Some(LoadResult::Loaded(loaded)) => { 85 rsx! { 86 MarkdownEditorInner { 87 - key: "{draft_key}", 88 - draft_key: draft_key.clone(), 89 - loaded_entry: Some(loaded.clone()), 90 - initial_content: None, 91 } 92 } 93 } 94 - Some(LoadResult::Failed) => { 95 rsx! { 96 div { class: "editor-error", 97 - "Failed to load entry. It may not exist or you may not have access." 98 - } 99 - } 100 - } 101 - Some(LoadResult::NotNeeded) => { 102 - rsx! { 103 - MarkdownEditorInner { 104 - key: "{draft_key}", 105 - draft_key: draft_key.clone(), 106 - loaded_entry: None, 107 - initial_content: initial_content.clone(), 108 } 109 } 110 } 111 - None => { 112 - // Still loading 113 rsx! { 114 div { class: "editor-loading", 115 - "Loading entry..." 116 } 117 } 118 } ··· 126 /// - Event interception for full control over editing operations 127 /// - Toolbar formatting buttons 128 /// - LocalStorage auto-save with debouncing 129 /// - Keyboard shortcuts (Ctrl+B for bold, Ctrl+I for italic) 130 #[component] 131 fn MarkdownEditorInner( 132 draft_key: String, 133 - loaded_entry: Option<LoadedEntry>, 134 - initial_content: Option<String>, 135 ) -> Element { 136 // Context for authenticated API calls 137 let fetcher = use_context::<Fetcher>(); 138 let auth_state = use_context::<Signal<AuthState>>(); 139 140 let mut document = use_hook(|| { 141 - // Priority: loaded_entry > local storage > new with initial_content 142 - if let Some(ref loaded) = loaded_entry { 143 - let doc = EditorDocument::from_entry(&loaded.entry, loaded.entry_ref.clone()); 144 - // Save to local storage so future visits use the draft 145 - storage::save_to_storage(&doc, &draft_key).ok(); 146 - doc 147 - } else { 148 - storage::load_from_storage(&draft_key) 149 - .unwrap_or_else(|| EditorDocument::new(initial_content.clone().unwrap_or_default())) 150 - } 151 }); 152 let editor_id = "markdown-editor"; 153 ··· 483 } 484 } 485 486 - PublishButton { 487 - document: document.clone(), 488 - draft_key: draft_key.to_string(), 489 } 490 } 491
··· 23 use super::offset_map::SnapDirection; 24 use super::paragraph::ParagraphRender; 25 use super::platform; 26 + use super::document::LoadedDocState; 27 use super::publish::{LoadedEntry, PublishButton, load_entry_for_editing}; 28 use super::render; 29 use super::storage; 30 + use super::sync::{SyncStatus, load_and_merge_document}; 31 use super::toolbar::EditorToolbar; 32 use super::visibility::update_syntax_visibility; 33 use super::writer::{EditorImageResolver, SyntaxSpanInfo}; 34 35 + /// Result of loading document state. 36 enum LoadResult { 37 + /// Document state loaded (may be merged from PDS + localStorage) 38 + Loaded(LoadedDocState), 39 + /// Loading failed 40 + Failed(String), 41 + /// Still loading 42 + Loading, 43 } 44 45 + /// Wrapper component that handles loading document state before rendering the editor. 46 + /// 47 + /// Loads and merges state from: 48 + /// - localStorage (local CRDT snapshot) 49 + /// - PDS edit state (if editing published entry) 50 + /// - Entry content (if no edit state exists) 51 /// 52 /// # Props 53 /// - `initial_content`: Optional initial markdown content (for new entries) ··· 56 pub fn MarkdownEditor(initial_content: Option<String>, entry_uri: Option<String>) -> Element { 57 let fetcher = use_context::<Fetcher>(); 58 59 + // Determine draft key - use entry URI if editing existing, otherwise generate TID 60 + let draft_key = use_hook(|| { 61 + entry_uri.clone().unwrap_or_else(|| { 62 + format!("new:{}", jacquard::types::tid::Ticker::new().next(None).as_str()) 63 + }) 64 + }); 65 66 + // Parse entry URI once 67 + let parsed_uri = entry_uri.as_ref().and_then(|s| { 68 + jacquard::types::string::AtUri::new(s).ok().map(|u| u.into_static()) 69 + }); 70 71 + // Clone draft_key for render (resource closure moves it) 72 + let draft_key_for_render = draft_key.clone(); 73 74 + // Resource loads and merges document state 75 + let load_resource = use_resource(move || { 76 let fetcher = fetcher.clone(); 77 + let draft_key = draft_key.clone(); 78 + let entry_uri = parsed_uri.clone(); 79 + let initial_content = initial_content.clone(); 80 + 81 async move { 82 + // Try to load merged state from PDS + localStorage 83 + match load_and_merge_document(&fetcher, &draft_key, entry_uri.as_ref()).await { 84 + Ok(Some(state)) => { 85 + tracing::debug!("Loaded merged document state"); 86 + return LoadResult::Loaded(state); 87 + } 88 + Ok(None) => { 89 + // No existing state - check if we need to load entry content 90 + if let Some(ref uri) = entry_uri { 91 + // Try to load the entry content from PDS 92 + match load_entry_for_editing(&fetcher, uri).await { 93 + Ok(loaded) => { 94 + // Create LoadedDocState from entry 95 + let doc = loro::LoroDoc::new(); 96 + let content = doc.get_text("content"); 97 + let title = doc.get_text("title"); 98 + let path = doc.get_text("path"); 99 + let tags = doc.get_list("tags"); 100 + 101 + content.insert(0, loaded.entry.content.as_ref()).ok(); 102 + title.insert(0, loaded.entry.title.as_ref()).ok(); 103 + path.insert(0, loaded.entry.path.as_ref()).ok(); 104 + if let Some(ref entry_tags) = loaded.entry.tags { 105 + for tag in entry_tags { 106 + let tag_str: &str = tag.as_ref(); 107 + tags.push(tag_str).ok(); 108 + } 109 + } 110 + doc.commit(); 111 + 112 + return LoadResult::Loaded(LoadedDocState { 113 + doc, 114 + entry_ref: Some(loaded.entry_ref), 115 + edit_root: None, 116 + last_diff: None, 117 + is_synced: false, 118 + }); 119 + } 120 + Err(e) => { 121 + tracing::error!("Failed to load entry: {}", e); 122 + return LoadResult::Failed(e.to_string()); 123 + } 124 } 125 } 126 + 127 + // New document with initial content 128 + let doc = loro::LoroDoc::new(); 129 + if let Some(ref content) = initial_content { 130 + let text = doc.get_text("content"); 131 + text.insert(0, content).ok(); 132 + doc.commit(); 133 + } 134 + 135 + LoadResult::Loaded(LoadedDocState { 136 + doc, 137 + entry_ref: None, 138 + edit_root: None, 139 + last_diff: None, 140 + is_synced: false, 141 + }) 142 + } 143 + Err(e) => { 144 + tracing::error!("Failed to load document state: {}", e); 145 + LoadResult::Failed(e.to_string()) 146 } 147 } 148 } 149 }); 150 151 + // Render based on load state 152 + match &*load_resource.read() { 153 + Some(LoadResult::Loaded(state)) => { 154 rsx! { 155 MarkdownEditorInner { 156 + key: "{draft_key_for_render}", 157 + draft_key: draft_key_for_render.clone(), 158 + loaded_state: state.clone(), 159 } 160 } 161 } 162 + Some(LoadResult::Failed(err)) => { 163 rsx! { 164 div { class: "editor-error", 165 + "Failed to load: {err}" 166 } 167 } 168 } 169 + Some(LoadResult::Loading) | None => { 170 rsx! { 171 div { class: "editor-loading", 172 + "Loading..." 173 } 174 } 175 } ··· 183 /// - Event interception for full control over editing operations 184 /// - Toolbar formatting buttons 185 /// - LocalStorage auto-save with debouncing 186 + /// - PDS sync with auto-save 187 /// - Keyboard shortcuts (Ctrl+B for bold, Ctrl+I for italic) 188 #[component] 189 fn MarkdownEditorInner( 190 draft_key: String, 191 + loaded_state: LoadedDocState, 192 ) -> Element { 193 // Context for authenticated API calls 194 let fetcher = use_context::<Fetcher>(); 195 let auth_state = use_context::<Signal<AuthState>>(); 196 197 + // Create EditorDocument from loaded state (must be in use_hook for Signals) 198 let mut document = use_hook(|| { 199 + let doc = EditorDocument::from_loaded_state(loaded_state.clone()); 200 + // Save to localStorage so we have a local backup 201 + storage::save_to_storage(&doc, &draft_key).ok(); 202 + doc 203 }); 204 let editor_id = "markdown-editor"; 205 ··· 535 } 536 } 537 538 + div { class: "meta-actions", 539 + SyncStatus { 540 + document: document.clone(), 541 + draft_key: draft_key.to_string(), 542 + } 543 + 544 + PublishButton { 545 + document: document.clone(), 546 + draft_key: draft_key.to_string(), 547 + } 548 } 549 } 550
+95 -2
crates/weaver-app/src/components/editor/document.rs
··· 194 /// Covers: `######` (6), ```` ``` ```` (3), `> ` (2), `- ` (2), `999. ` (5) 195 const BLOCK_SYNTAX_ZONE: usize = 6; 196 197 impl EditorDocument { 198 /// Check if a character position is within the block-syntax zone of its line. 199 fn is_in_block_syntax_zone(&self, pos: usize) -> bool { ··· 787 self.last_diff.set(diff); 788 } 789 790 - /// Check if there are unsynchronized changes since the last sync. 791 - pub fn has_unsync_changes(&self) -> bool { 792 match &self.last_synced_version { 793 Some(synced_vv) => self.doc.oplog_vv() != *synced_vv, 794 None => true, // Never synced, so there are changes ··· 921 undo_mgr: Rc::new(RefCell::new(undo_mgr)), 922 loro_cursor, 923 // Reactive editor state - wrapped in Signals 924 cursor: Signal::new(cursor_state), 925 selection: Signal::new(None), 926 composition: Signal::new(None),
··· 194 /// Covers: `######` (6), ```` ``` ```` (3), `> ` (2), `- ` (2), `999. ` (5) 195 const BLOCK_SYNTAX_ZONE: usize = 6; 196 197 + /// Pre-loaded document state that can be created outside of reactive context. 198 + /// 199 + /// This struct holds the raw LoroDoc (which is safe outside reactive context) 200 + /// along with sync state metadata. Use `EditorDocument::from_loaded_state()` 201 + /// inside a `use_hook` to convert this into a reactive EditorDocument. 202 + /// 203 + /// Note: Clone is a shallow/reference clone for LoroDoc (Arc-backed). 204 + /// PartialEq always returns false since we can't meaningfully compare docs. 205 + #[derive(Clone)] 206 + pub struct LoadedDocState { 207 + /// The Loro document with all content already loaded/merged. 208 + pub doc: LoroDoc, 209 + /// StrongRef to the entry if editing an existing record. 210 + pub entry_ref: Option<StrongRef<'static>>, 211 + /// StrongRef to the sh.weaver.edit.root record (for PDS sync). 212 + pub edit_root: Option<StrongRef<'static>>, 213 + /// StrongRef to the most recent sh.weaver.edit.diff record. 214 + pub last_diff: Option<StrongRef<'static>>, 215 + /// Whether the current doc state is synced with PDS. 216 + /// False if local has changes not yet pushed to PDS. 217 + pub is_synced: bool, 218 + } 219 + 220 + impl PartialEq for LoadedDocState { 221 + fn eq(&self, _other: &Self) -> bool { 222 + // LoadedDocState contains LoroDoc which can't be meaningfully compared. 223 + // Return false to ensure components re-render when passed as props. 224 + false 225 + } 226 + } 227 + 228 impl EditorDocument { 229 /// Check if a character position is within the block-syntax zone of its line. 230 fn is_in_block_syntax_zone(&self, pos: usize) -> bool { ··· 818 self.last_diff.set(diff); 819 } 820 821 + /// Check if there are unsynced changes since the last PDS sync. 822 + pub fn has_unsynced_changes(&self) -> bool { 823 match &self.last_synced_version { 824 Some(synced_vv) => self.doc.oplog_vv() != *synced_vv, 825 None => true, // Never synced, so there are changes ··· 952 undo_mgr: Rc::new(RefCell::new(undo_mgr)), 953 loro_cursor, 954 // Reactive editor state - wrapped in Signals 955 + cursor: Signal::new(cursor_state), 956 + selection: Signal::new(None), 957 + composition: Signal::new(None), 958 + composition_ended_at: Signal::new(None), 959 + last_edit: Signal::new(None), 960 + pending_snap: Signal::new(None), 961 + } 962 + } 963 + 964 + /// Create an EditorDocument from pre-loaded state. 965 + /// 966 + /// Use this when loading from PDS/localStorage merge outside reactive context. 967 + /// The `LoadedDocState` contains a pre-merged LoroDoc; this method wraps it 968 + /// with the reactive Signals needed for the editor UI. 969 + /// 970 + /// # Note 971 + /// This creates Dioxus Signals. Call from within a component using `use_hook`. 972 + pub fn from_loaded_state(state: LoadedDocState) -> Self { 973 + let doc = state.doc; 974 + 975 + // Get all containers from the loaded doc 976 + let content = doc.get_text("content"); 977 + let title = doc.get_text("title"); 978 + let path = doc.get_text("path"); 979 + let created_at = doc.get_text("created_at"); 980 + let tags = doc.get_list("tags"); 981 + let embeds = doc.get_map("embeds"); 982 + 983 + // Set up undo manager 984 + let mut undo_mgr = UndoManager::new(&doc); 985 + undo_mgr.set_merge_interval(300); 986 + undo_mgr.set_max_undo_steps(100); 987 + 988 + // Position cursor at end of content 989 + let cursor_offset = content.len_unicode(); 990 + let cursor_state = CursorState { 991 + offset: cursor_offset, 992 + affinity: Affinity::Before, 993 + }; 994 + let loro_cursor = content.get_cursor(cursor_offset, Side::default()); 995 + 996 + // Track sync state - if synced, record current version 997 + let last_synced_version = if state.is_synced { 998 + Some(doc.oplog_vv()) 999 + } else { 1000 + None 1001 + }; 1002 + 1003 + Self { 1004 + doc, 1005 + content, 1006 + title, 1007 + path, 1008 + created_at, 1009 + tags, 1010 + embeds, 1011 + entry_ref: Signal::new(state.entry_ref), 1012 + edit_root: Signal::new(state.edit_root), 1013 + last_diff: Signal::new(state.last_diff), 1014 + last_synced_version, 1015 + undo_mgr: Rc::new(RefCell::new(undo_mgr)), 1016 + loro_cursor, 1017 cursor: Signal::new(cursor_state), 1018 selection: Signal::new(None), 1019 composition: Signal::new(None),
+9 -2
crates/weaver-app/src/components/editor/mod.rs
··· 32 33 // Document types 34 #[allow(unused_imports)] 35 - pub use document::{Affinity, CompositionState, CursorState, EditorDocument, Selection}; 36 37 // Formatting 38 #[allow(unused_imports)] ··· 52 #[allow(unused_imports)] 53 pub use storage::{ 54 DRAFT_KEY_PREFIX, EditorSnapshot, clear_all_drafts, delete_draft, list_drafts, 55 - load_from_storage, save_to_storage, 56 }; 57 58 // UI components
··· 32 33 // Document types 34 #[allow(unused_imports)] 35 + pub use document::{Affinity, CompositionState, CursorState, EditorDocument, LoadedDocState, Selection}; 36 37 // Formatting 38 #[allow(unused_imports)] ··· 52 #[allow(unused_imports)] 53 pub use storage::{ 54 DRAFT_KEY_PREFIX, EditorSnapshot, clear_all_drafts, delete_draft, list_drafts, 55 + load_from_storage, load_snapshot_from_storage, save_to_storage, 56 + }; 57 + 58 + // Sync 59 + #[allow(unused_imports)] 60 + pub use sync::{ 61 + load_and_merge_document, load_edit_state_from_pds, sync_to_pds, 62 + PdsEditState, SyncState, SyncStatus, 63 }; 64 65 // UI components
+48
crates/weaver-app/src/components/editor/storage.rs
··· 146 Some(doc) 147 } 148 149 /// Delete a draft from LocalStorage (WASM only). 150 /// 151 /// # Arguments
··· 146 Some(doc) 147 } 148 149 + /// Data loaded from localStorage snapshot. 150 + pub struct LocalSnapshotData { 151 + /// The raw CRDT snapshot bytes 152 + pub snapshot: Vec<u8>, 153 + /// Entry StrongRef if editing an existing entry 154 + pub entry_ref: Option<StrongRef<'static>>, 155 + } 156 + 157 + /// Load snapshot data from LocalStorage (WASM only). 158 + /// 159 + /// Unlike `load_from_storage`, this doesn't create an EditorDocument and is safe 160 + /// to call outside of reactive context. Use with `load_and_merge_document`. 161 + /// 162 + /// # Arguments 163 + /// * `key` - Storage key (e.g., "new:abc123" for new entries, or AT-URI for existing) 164 + #[cfg(all(target_family = "wasm", target_os = "unknown"))] 165 + pub fn load_snapshot_from_storage(key: &str) -> Option<LocalSnapshotData> { 166 + let snapshot: EditorSnapshot = LocalStorage::get(storage_key(key)).ok()?; 167 + 168 + // Try to get CRDT snapshot bytes 169 + let snapshot_bytes = snapshot 170 + .snapshot 171 + .as_ref() 172 + .and_then(|b64| BASE64.decode(b64).ok())?; 173 + 174 + // Try to reconstruct entry_ref from stored URI + CID 175 + let entry_ref = snapshot 176 + .editing_uri 177 + .as_ref() 178 + .zip(snapshot.editing_cid.as_ref()) 179 + .and_then(|(uri_str, cid_str)| { 180 + let uri = AtUri::new(uri_str).ok()?.into_static(); 181 + let cid = Cid::new(cid_str.as_bytes()).ok()?.into_static(); 182 + Some(StrongRef::new().uri(uri).cid(cid).build()) 183 + }); 184 + 185 + Some(LocalSnapshotData { 186 + snapshot: snapshot_bytes, 187 + entry_ref, 188 + }) 189 + } 190 + 191 + /// Load snapshot data from LocalStorage (non-WASM stub). 192 + #[cfg(not(all(target_family = "wasm", target_os = "unknown")))] 193 + pub fn load_snapshot_from_storage(_key: &str) -> Option<LocalSnapshotData> { 194 + None 195 + } 196 + 197 /// Delete a draft from LocalStorage (WASM only). 198 /// 199 /// # Arguments
+305 -48
crates/weaver-app/src/components/editor/sync.rs
··· 18 19 use std::collections::BTreeMap; 20 21 use jacquard::cowstr::ToCowStr; 22 use jacquard::prelude::*; 23 use jacquard::types::blob::MimeType; ··· 40 41 use crate::fetch::Fetcher; 42 43 - use super::document::EditorDocument; 44 45 const ROOT_NSID: &str = "sh.weaver.edit.root"; 46 const DIFF_NSID: &str = "sh.weaver.edit.diff"; ··· 274 .await 275 .ok_or_else(|| WeaverError::InvalidNotebook("Not authenticated".into()))?; 276 277 - // Upload updates blob 278 - let mime_type = MimeType::new_static("application/octet-stream"); 279 - let blob_ref = client 280 - .upload_blob(updates, mime_type) 281 - .await 282 - .map_err(|e| WeaverError::InvalidNotebook(format!("Failed to upload diff: {}", e)))?; 283 284 // Build DocRef - use EntryRef if published, DraftRef if not 285 let doc_ref = build_doc_ref(draft_key, entry_uri, entry_cid); ··· 302 let diff = Diff::new() 303 .doc(doc_ref) 304 .root(root_ref) 305 - .snapshot(blob_ref) 306 .maybe_prev(prev_ref) 307 .build(); 308 ··· 355 draft_key: &str, 356 ) -> Result<SyncResult, WeaverError> { 357 // Check if we have changes to sync 358 - if !doc.has_unsync_changes() { 359 return Ok(SyncResult::NoChanges); 360 } 361 ··· 434 /// The latest diff reference (if any diffs exist) 435 pub last_diff_ref: Option<StrongRef<'static>>, 436 /// The Loro snapshot bytes from the root 437 - pub root_snapshot: Vec<u8>, 438 /// All diff update bytes in order (oldest first, by TID) 439 - pub diff_updates: Vec<Vec<u8>>, 440 } 441 442 /// Fetch a blob from the PDS. 443 - async fn fetch_blob( 444 - fetcher: &Fetcher, 445 - did: &Did<'_>, 446 - cid: &Cid<'_>, 447 - ) -> Result<Vec<u8>, WeaverError> { 448 let pds_url = fetcher 449 .client 450 .pds_for_did(did) ··· 464 WeaverError::InvalidNotebook(format!("Failed to parse blob response: {}", e)) 465 })?; 466 467 - Ok(output.body.to_vec()) 468 } 469 470 /// Load edit state from the PDS for an entry. ··· 581 ); 582 } 583 584 - // Fetch all diff blobs in TID order (BTreeMap iterates in sorted order) 585 let mut diff_updates = Vec::new(); 586 let mut last_diff_ref = None; 587 588 for (_rkey, (diff, cid, uri)) in &diffs_by_rkey { 589 - let blob_bytes = fetch_blob(fetcher, &root_id.did(), diff.snapshot.blob().cid()).await?; 590 - diff_updates.push(blob_bytes); 591 592 // Track the last diff (will be the one with highest TID after iteration) 593 last_diff_ref = Some(StrongRef::new().uri(uri.clone()).cid(cid.clone()).build()); ··· 601 })) 602 } 603 604 - /// Load an EditorDocument by merging local storage and PDS state. 605 /// 606 /// This is the main entry point for loading a document with full sync support. 607 /// It: ··· 609 /// 2. Loads from PDS (if available) 610 /// 3. Merges both using Loro's CRDT merge 611 /// 612 - /// The result is a document with all changes from both sources. 613 /// 614 /// # Arguments 615 /// * `fetcher` - The authenticated fetcher ··· 617 /// * `entry_uri` - Optional AT-URI if editing an existing entry 618 /// 619 /// # Returns 620 - /// A merged EditorDocument, or None if no state exists anywhere. 621 pub async fn load_and_merge_document( 622 fetcher: &Fetcher, 623 draft_key: &str, 624 entry_uri: Option<&AtUri<'_>>, 625 - ) -> Result<Option<EditorDocument>, WeaverError> { 626 - use super::storage::load_from_storage; 627 628 - // Load from localStorage 629 - let local_doc = load_from_storage(draft_key); 630 631 // Load from PDS (only if we have an entry URI) 632 let pds_state = if let Some(uri) = entry_uri { ··· 635 None 636 }; 637 638 - match (local_doc, pds_state) { 639 (None, None) => Ok(None), 640 641 - (Some(doc), None) => { 642 - // Only local state exists 643 tracing::debug!("Loaded document from localStorage only"); 644 - Ok(Some(doc)) 645 } 646 647 (None, Some(pds)) => { 648 // Only PDS state exists - reconstruct from snapshot + diffs 649 tracing::debug!("Loaded document from PDS only"); 650 - let mut doc = EditorDocument::from_snapshot(&pds.root_snapshot, None, 0); 651 652 // Apply all diffs in order 653 for updates in &pds.diff_updates { 654 - if let Err(e) = doc.import_updates(updates) { 655 tracing::warn!("Failed to apply diff update: {:?}", e); 656 } 657 } 658 659 - // Set sync state so we don't re-upload what we just downloaded 660 - doc.set_synced_from_pds(pds.root_ref, pds.last_diff_ref); 661 - 662 - Ok(Some(doc)) 663 } 664 665 - (Some(mut local_doc), Some(pds)) => { 666 // Both exist - merge using CRDT 667 tracing::debug!("Merging document from localStorage and PDS"); 668 669 - // Import PDS root snapshot into local doc 670 - // Loro will automatically merge concurrent changes 671 - if let Err(e) = local_doc.import_updates(&pds.root_snapshot) { 672 tracing::warn!("Failed to merge PDS root snapshot: {:?}", e); 673 } 674 675 // Import all diffs 676 for updates in &pds.diff_updates { 677 - if let Err(e) = local_doc.import_updates(updates) { 678 tracing::warn!("Failed to merge PDS diff: {:?}", e); 679 } 680 } 681 682 - // Update sync state 683 - // We keep the PDS root/diff refs since that's where we'll push updates 684 - local_doc.set_edit_root(Some(pds.root_ref)); 685 - local_doc.set_last_diff(pds.last_diff_ref); 686 - // Don't call set_synced_from_pds - local changes still need syncing 687 688 - Ok(Some(local_doc)) 689 } 690 } 691 }
··· 18 19 use std::collections::BTreeMap; 20 21 + use jacquard::bytes::Bytes; 22 use jacquard::cowstr::ToCowStr; 23 use jacquard::prelude::*; 24 use jacquard::types::blob::MimeType; ··· 41 42 use crate::fetch::Fetcher; 43 44 + use super::document::{EditorDocument, LoadedDocState}; 45 + use loro::LoroDoc; 46 47 const ROOT_NSID: &str = "sh.weaver.edit.root"; 48 const DIFF_NSID: &str = "sh.weaver.edit.diff"; ··· 276 .await 277 .ok_or_else(|| WeaverError::InvalidNotebook("Not authenticated".into()))?; 278 279 + // Threshold for inline vs blob storage (8KB max for inline per lexicon) 280 + const INLINE_THRESHOLD: usize = 8192; 281 + 282 + // Use inline for small diffs, blob for larger ones 283 + let (blob_ref, inline_diff): (Option<jacquard::types::blob::BlobRef<'static>>, _) = 284 + if updates.len() <= INLINE_THRESHOLD { 285 + tracing::debug!("Using inline diff ({} bytes)", updates.len()); 286 + (None, Some(jacquard::bytes::Bytes::from(updates))) 287 + } else { 288 + tracing::debug!("Using blob diff ({} bytes)", updates.len()); 289 + let mime_type = MimeType::new_static("application/octet-stream"); 290 + let blob = client.upload_blob(updates, mime_type).await.map_err(|e| { 291 + WeaverError::InvalidNotebook(format!("Failed to upload diff: {}", e)) 292 + })?; 293 + (Some(blob.into()), None) 294 + }; 295 296 // Build DocRef - use EntryRef if published, DraftRef if not 297 let doc_ref = build_doc_ref(draft_key, entry_uri, entry_cid); ··· 314 let diff = Diff::new() 315 .doc(doc_ref) 316 .root(root_ref) 317 + .maybe_snapshot(blob_ref) 318 + .maybe_inline_diff(inline_diff) 319 .maybe_prev(prev_ref) 320 .build(); 321 ··· 368 draft_key: &str, 369 ) -> Result<SyncResult, WeaverError> { 370 // Check if we have changes to sync 371 + if !doc.has_unsynced_changes() { 372 return Ok(SyncResult::NoChanges); 373 } 374 ··· 447 /// The latest diff reference (if any diffs exist) 448 pub last_diff_ref: Option<StrongRef<'static>>, 449 /// The Loro snapshot bytes from the root 450 + pub root_snapshot: Bytes, 451 /// All diff update bytes in order (oldest first, by TID) 452 + pub diff_updates: Vec<Bytes>, 453 } 454 455 /// Fetch a blob from the PDS. 456 + async fn fetch_blob(fetcher: &Fetcher, did: &Did<'_>, cid: &Cid<'_>) -> Result<Bytes, WeaverError> { 457 let pds_url = fetcher 458 .client 459 .pds_for_did(did) ··· 473 WeaverError::InvalidNotebook(format!("Failed to parse blob response: {}", e)) 474 })?; 475 476 + Ok(output.body) 477 } 478 479 /// Load edit state from the PDS for an entry. ··· 590 ); 591 } 592 593 + // Fetch all diff data in TID order (BTreeMap iterates in sorted order) 594 + // Diffs can be stored either inline or as blobs 595 let mut diff_updates = Vec::new(); 596 let mut last_diff_ref = None; 597 598 for (_rkey, (diff, cid, uri)) in &diffs_by_rkey { 599 + // Check for inline diff first, then fall back to blob 600 + let diff_bytes = if let Some(ref inline) = diff.inline_diff { 601 + inline.clone() 602 + } else if let Some(ref snapshot) = diff.snapshot { 603 + fetch_blob(fetcher, &root_id.did(), snapshot.blob().cid()).await? 604 + } else { 605 + tracing::warn!("Diff has neither inline_diff nor snapshot, skipping"); 606 + continue; 607 + }; 608 + 609 + diff_updates.push(diff_bytes); 610 611 // Track the last diff (will be the one with highest TID after iteration) 612 last_diff_ref = Some(StrongRef::new().uri(uri.clone()).cid(cid.clone()).build()); ··· 620 })) 621 } 622 623 + /// Load document state by merging local storage and PDS state. 624 /// 625 /// This is the main entry point for loading a document with full sync support. 626 /// It: ··· 628 /// 2. Loads from PDS (if available) 629 /// 3. Merges both using Loro's CRDT merge 630 /// 631 + /// The result is a `LoadedDocState` containing a pre-merged LoroDoc that can be 632 + /// converted to an EditorDocument inside a reactive context using `use_hook`. 633 /// 634 /// # Arguments 635 /// * `fetcher` - The authenticated fetcher ··· 637 /// * `entry_uri` - Optional AT-URI if editing an existing entry 638 /// 639 /// # Returns 640 + /// A `LoadedDocState` with merged state, or None if no state exists anywhere. 641 pub async fn load_and_merge_document( 642 fetcher: &Fetcher, 643 draft_key: &str, 644 entry_uri: Option<&AtUri<'_>>, 645 + ) -> Result<Option<LoadedDocState>, WeaverError> { 646 + use super::storage::load_snapshot_from_storage; 647 648 + // Load snapshot + entry_ref from localStorage 649 + let local_data = load_snapshot_from_storage(draft_key); 650 651 // Load from PDS (only if we have an entry URI) 652 let pds_state = if let Some(uri) = entry_uri { ··· 655 None 656 }; 657 658 + match (local_data, pds_state) { 659 (None, None) => Ok(None), 660 661 + (Some(local), None) => { 662 + // Only local state exists - build LoroDoc from snapshot 663 tracing::debug!("Loaded document from localStorage only"); 664 + let doc = LoroDoc::new(); 665 + if let Err(e) = doc.import(&local.snapshot) { 666 + tracing::warn!("Failed to import local snapshot: {:?}", e); 667 + } 668 + 669 + Ok(Some(LoadedDocState { 670 + doc, 671 + entry_ref: local.entry_ref, // Restored from localStorage 672 + edit_root: None, 673 + last_diff: None, 674 + is_synced: false, // Local-only, not synced to PDS 675 + })) 676 } 677 678 (None, Some(pds)) => { 679 // Only PDS state exists - reconstruct from snapshot + diffs 680 tracing::debug!("Loaded document from PDS only"); 681 + let doc = LoroDoc::new(); 682 + 683 + // Import root snapshot 684 + if let Err(e) = doc.import(&pds.root_snapshot) { 685 + tracing::warn!("Failed to import PDS root snapshot: {:?}", e); 686 + } 687 688 // Apply all diffs in order 689 for updates in &pds.diff_updates { 690 + if let Err(e) = doc.import(updates) { 691 tracing::warn!("Failed to apply diff update: {:?}", e); 692 } 693 } 694 695 + Ok(Some(LoadedDocState { 696 + doc, 697 + entry_ref: None, // Entry ref comes from the entry itself, not edit state 698 + edit_root: Some(pds.root_ref), 699 + last_diff: pds.last_diff_ref, 700 + is_synced: true, // Just loaded from PDS, fully synced 701 + })) 702 } 703 704 + (Some(local), Some(pds)) => { 705 // Both exist - merge using CRDT 706 tracing::debug!("Merging document from localStorage and PDS"); 707 708 + let doc = LoroDoc::new(); 709 + 710 + // Import local snapshot first 711 + if let Err(e) = doc.import(&local.snapshot) { 712 + tracing::warn!("Failed to import local snapshot: {:?}", e); 713 + } 714 + 715 + // Import PDS root snapshot - Loro will merge 716 + if let Err(e) = doc.import(&pds.root_snapshot) { 717 tracing::warn!("Failed to merge PDS root snapshot: {:?}", e); 718 } 719 720 // Import all diffs 721 for updates in &pds.diff_updates { 722 + if let Err(e) = doc.import(updates) { 723 tracing::warn!("Failed to merge PDS diff: {:?}", e); 724 } 725 } 726 727 + Ok(Some(LoadedDocState { 728 + doc, 729 + entry_ref: local.entry_ref, // Restored from localStorage 730 + edit_root: Some(pds.root_ref), 731 + last_diff: pds.last_diff_ref, 732 + is_synced: false, // Local had state, may have unsynced changes 733 + })) 734 + } 735 + } 736 + } 737 + 738 + // ============================================================================ 739 + // Sync UI Components 740 + // ============================================================================ 741 + 742 + use crate::auth::AuthState; 743 + use dioxus::prelude::*; 744 + 745 + /// Sync status states for UI display. 746 + #[derive(Clone, Copy, PartialEq, Eq, Debug)] 747 + pub enum SyncState { 748 + /// All local changes have been synced to PDS 749 + Synced, 750 + /// Currently syncing to PDS 751 + Syncing, 752 + /// Has local changes not yet synced 753 + Unsynced, 754 + /// Last sync failed 755 + Error, 756 + /// Not authenticated or sync disabled 757 + Disabled, 758 + } 759 + 760 + /// Props for the SyncStatus component. 761 + #[derive(Props, Clone, PartialEq)] 762 + pub struct SyncStatusProps { 763 + /// The editor document to sync 764 + pub document: EditorDocument, 765 + /// Draft key for this document 766 + pub draft_key: String, 767 + /// Auto-sync interval in milliseconds (0 to disable) 768 + #[props(default = 30_000)] 769 + pub auto_sync_interval_ms: u32, 770 + } 771 + 772 + /// Sync status indicator with auto-sync functionality. 773 + /// 774 + /// Displays the current sync state and automatically syncs to PDS periodically. 775 + #[component] 776 + pub fn SyncStatus(props: SyncStatusProps) -> Element { 777 + let fetcher = use_context::<Fetcher>(); 778 + let auth_state = use_context::<Signal<AuthState>>(); 779 780 + // Sync state management 781 + let mut sync_state = use_signal(|| { 782 + if props.document.has_unsynced_changes() { 783 + SyncState::Unsynced 784 + } else { 785 + SyncState::Synced 786 + } 787 + }); 788 + let mut last_error: Signal<Option<String>> = use_signal(|| None); 789 + 790 + let doc = props.document.clone(); 791 + let draft_key = props.draft_key.clone(); 792 + 793 + // Check if we're authenticated and have an entry to sync 794 + let is_authenticated = auth_state.read().is_authenticated(); 795 + let has_entry = doc.entry_ref().is_some(); 796 + 797 + // Auto-sync trigger signal - set to true to trigger a sync 798 + let mut trigger_sync = use_signal(|| false); 799 + 800 + // Auto-sync timer (WASM only) - just sets the trigger, doesn't access signals directly 801 + #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 802 + { 803 + let auto_sync_interval = props.auto_sync_interval_ms; 804 + let doc_for_check = doc.clone(); 805 + 806 + use_effect(move || { 807 + if auto_sync_interval == 0 { 808 + return; 809 + } 810 + 811 + let doc = doc_for_check.clone(); 812 + 813 + let interval = gloo_timers::callback::Interval::new(auto_sync_interval, move || { 814 + // Only trigger if there are unsynced changes and we're not already syncing 815 + if doc.has_unsynced_changes() { 816 + // This just sets a signal - the actual sync happens in use_future below 817 + trigger_sync.set(true); 818 + } 819 + }); 820 + 821 + interval.forget(); 822 + }); 823 + } 824 + 825 + #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))] 826 + let mut trigger_sync = use_signal(|| false); 827 + 828 + // Update sync state when document changes 829 + // Note: We use peek() to avoid creating a reactive dependency on sync_state 830 + let doc_for_effect = doc.clone(); 831 + use_effect(move || { 832 + // Check for unsynced changes (reads last_edit signal for reactivity) 833 + let _edit = doc_for_effect.last_edit(); 834 + 835 + // Use peek to avoid reactive loop 836 + let current_state = *sync_state.peek(); 837 + if current_state != SyncState::Syncing { 838 + if doc_for_effect.has_unsynced_changes() && current_state != SyncState::Unsynced { 839 + sync_state.set(SyncState::Unsynced); 840 + } 841 + } 842 + }); 843 + 844 + // Sync effect - watches trigger_sync and performs sync when triggered 845 + let doc_for_sync = doc.clone(); 846 + let draft_key_for_sync = draft_key.clone(); 847 + let fetcher_for_sync = fetcher.clone(); 848 + 849 + let doc_for_check = doc.clone(); 850 + use_effect(move || { 851 + // Read trigger to create reactive dependency 852 + let should_sync = *trigger_sync.read(); 853 + 854 + if !should_sync { 855 + return; 856 + } 857 + 858 + // Reset trigger immediately 859 + trigger_sync.set(false); 860 + 861 + // Check if already syncing 862 + if *sync_state.peek() == SyncState::Syncing { 863 + return; 864 + } 865 + 866 + // Check if authenticated and has entry 867 + if !is_authenticated || !has_entry { 868 + return; 869 + } 870 + 871 + // Check if there are actually changes to sync 872 + if !doc_for_check.has_unsynced_changes() { 873 + // Already synced, just update state 874 + sync_state.set(SyncState::Synced); 875 + return; 876 + } 877 + 878 + sync_state.set(SyncState::Syncing); 879 + 880 + let mut doc = doc_for_sync.clone(); 881 + let draft_key = draft_key_for_sync.clone(); 882 + let fetcher = fetcher_for_sync.clone(); 883 + 884 + // Spawn the async work 885 + spawn(async move { 886 + match sync_to_pds(&fetcher, &mut doc, &draft_key).await { 887 + Ok(SyncResult::NoChanges) => { 888 + // No changes to sync - already up to date 889 + sync_state.set(SyncState::Synced); 890 + last_error.set(None); 891 + tracing::debug!("No changes to sync"); 892 + } 893 + Ok(_) => { 894 + sync_state.set(SyncState::Synced); 895 + last_error.set(None); 896 + tracing::debug!("Sync completed successfully"); 897 + } 898 + Err(e) => { 899 + sync_state.set(SyncState::Error); 900 + last_error.set(Some(e.to_string())); 901 + tracing::warn!("Sync failed: {}", e); 902 + } 903 + } 904 + }); 905 + }); 906 + 907 + // Manual sync handler - just sets the trigger if there are changes 908 + let doc_for_manual = doc.clone(); 909 + let on_manual_sync = move |_| { 910 + if *sync_state.peek() == SyncState::Syncing { 911 + return; // Already syncing 912 + } 913 + if !doc_for_manual.has_unsynced_changes() { 914 + // Already synced 915 + sync_state.set(SyncState::Synced); 916 + return; 917 + } 918 + trigger_sync.set(true); 919 + }; 920 + 921 + // Determine display state 922 + let display_state = if !is_authenticated { 923 + SyncState::Disabled 924 + } else if !has_entry { 925 + SyncState::Disabled // Can't sync unpublished entries 926 + } else { 927 + *sync_state.read() 928 + }; 929 + 930 + let (icon, label, class) = match display_state { 931 + SyncState::Synced => ("✓", "Synced", "sync-status synced"), 932 + SyncState::Syncing => ("◌", "Syncing...", "sync-status syncing"), 933 + SyncState::Unsynced => ("●", "Unsynced", "sync-status unsynced"), 934 + SyncState::Error => ("✕", "Sync error", "sync-status error"), 935 + SyncState::Disabled => ("○", "Sync disabled", "sync-status disabled"), 936 + }; 937 + 938 + rsx! { 939 + div { 940 + class: "{class}", 941 + title: if let Some(ref err) = *last_error.read() { err.clone() } else { label.to_string() }, 942 + onclick: on_manual_sync, 943 + 944 + span { class: "sync-icon", "{icon}" } 945 + span { class: "sync-label", "{label}" } 946 } 947 } 948 }
+7 -1
lexicons/edit/diff.json
··· 8 "key": "tid", 9 "record": { 10 "type": "object", 11 - "required": ["snapshot", "root", "doc"], 12 "properties": { 13 "snapshot": { 14 "type": "blob", 15 "accept": ["*/*"], 16 "maxSize": 3000000 17 }, 18 "root": { 19 "type": "ref",
··· 8 "key": "tid", 9 "record": { 10 "type": "object", 11 + "required": ["root", "doc"], 12 "properties": { 13 "snapshot": { 14 "type": "blob", 15 + "description": "Diff from previous diff. Either this or inlineDiff must be present to be valid", 16 "accept": ["*/*"], 17 "maxSize": 3000000 18 + }, 19 + "inlineDiff": { 20 + "type": "bytes", 21 + "description": "An inline diff for for small edit batches. Either this or snapshot must be present to be valid", 22 + "maxLength": 8192 23 }, 24 "root": { 25 "type": "ref",