···194/// Covers: `######` (6), ```` ``` ```` (3), `> ` (2), `- ` (2), `999. ` (5)
195const BLOCK_SYNTAX_ZONE: usize = 6;
1960000000000000000000000000000000197impl 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 }
789790- /// 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
00000000000000000000000000000000000000000000000000000000000000924 cursor: Signal::new(cursor_state),
925 selection: Signal::new(None),
926 composition: Signal::new(None),
···194/// Covers: `######` (6), ```` ``` ```` (3), `> ` (2), `- ` (2), `999. ` (5)
195const BLOCK_SYNTAX_ZONE: usize = 6;
196197+/// 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+228impl 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 }
820821+ /// 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),
···146 Some(doc)
147}
148000000000000000000000000000000000000000000000000149/// Delete a draft from LocalStorage (WASM only).
150///
151/// # Arguments
···146 Some(doc)
147}
148149+/// 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
···1819use std::collections::BTreeMap;
20021use jacquard::cowstr::ToCowStr;
22use jacquard::prelude::*;
23use jacquard::types::blob::MimeType;
···4041use crate::fetch::Fetcher;
4243-use super::document::EditorDocument;
04445const ROOT_NSID: &str = "sh.weaver.edit.root";
46const DIFF_NSID: &str = "sh.weaver.edit.diff";
···274 .await
275 .ok_or_else(|| WeaverError::InvalidNotebook("Not authenticated".into()))?;
276277- // 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)))?;
0000000000283284 // 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)
0306 .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}
441442/// 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 })?;
466467- Ok(output.body.to_vec())
468}
469470/// Load edit state from the PDS for an entry.
···581 );
582 }
583584- // Fetch all diff blobs in TID order (BTreeMap iterates in sorted order)
0585 let mut diff_updates = Vec::new();
586 let mut last_diff_ref = None;
587588 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);
000000000591592 // 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}
603604-/// 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.
0613///
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.
621pub 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;
627628- // Load from localStorage
629- let local_doc = load_from_storage(draft_key);
630631 // Load from PDS (only if we have an entry URI)
632 let pds_state = if let Some(uri) = entry_uri {
···635 None
636 };
637638- match (local_doc, pds_state) {
639 (None, None) => Ok(None),
640641- (Some(doc), None) => {
642- // Only local state exists
643 tracing::debug!("Loaded document from localStorage only");
644- Ok(Some(doc))
00000000000645 }
646647 (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);
00000651652 // 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 }
658659- // 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))
000663 }
664665- (Some(mut local_doc), Some(pds)) => {
666 // Both exist - merge using CRDT
667 tracing::debug!("Merging document from localStorage and PDS");
668669- // 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) {
000000672 tracing::warn!("Failed to merge PDS root snapshot: {:?}", e);
673 }
674675 // 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 }
681682- // 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
00000000000000000000000000000000000000000000000687688- Ok(Some(local_doc))
000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000689 }
690 }
691}
···1819use std::collections::BTreeMap;
2021+use jacquard::bytes::Bytes;
22use jacquard::cowstr::ToCowStr;
23use jacquard::prelude::*;
24use jacquard::types::blob::MimeType;
···4142use crate::fetch::Fetcher;
4344+use super::document::{EditorDocument, LoadedDocState};
45+use loro::LoroDoc;
4647const ROOT_NSID: &str = "sh.weaver.edit.root";
48const DIFF_NSID: &str = "sh.weaver.edit.diff";
···276 .await
277 .ok_or_else(|| WeaverError::InvalidNotebook("Not authenticated".into()))?;
278279+ // 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+ };
295296 // 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}
454455/// Fetch a blob from the PDS.
456+async fn fetch_blob(fetcher: &Fetcher, did: &Did<'_>, cid: &Cid<'_>) -> Result<Bytes, WeaverError> {
0000457 let pds_url = fetcher
458 .client
459 .pds_for_did(did)
···473 WeaverError::InvalidNotebook(format!("Failed to parse blob response: {}", e))
474 })?;
475476+ Ok(output.body)
477}
478479/// Load edit state from the PDS for an entry.
···590 );
591 }
592593+ // 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;
597598 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);
610611 // 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}
622623+/// 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.
641pub 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;
647648+ // Load snapshot + entry_ref from localStorage
649+ let local_data = load_snapshot_from_storage(draft_key);
650651 // Load from PDS (only if we have an entry URI)
652 let pds_state = if let Some(uri) = entry_uri {
···655 None
656 };
657658+ match (local_data, pds_state) {
659 (None, None) => Ok(None),
660661+ (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 }
677678 (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+ }
687688 // 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 }
694695+ 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 }
703704+ (Some(local), Some(pds)) => {
705 // Both exist - merge using CRDT
706 tracing::debug!("Merging document from localStorage and PDS");
707708+ 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 }
719720 // 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 }
726727+ 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>>();
779780+ // 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}
···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",