···194194/// Covers: `######` (6), ```` ``` ```` (3), `> ` (2), `- ` (2), `999. ` (5)
195195const BLOCK_SYNTAX_ZONE: usize = 6;
196196197197+/// Pre-loaded document state that can be created outside of reactive context.
198198+///
199199+/// This struct holds the raw LoroDoc (which is safe outside reactive context)
200200+/// along with sync state metadata. Use `EditorDocument::from_loaded_state()`
201201+/// inside a `use_hook` to convert this into a reactive EditorDocument.
202202+///
203203+/// Note: Clone is a shallow/reference clone for LoroDoc (Arc-backed).
204204+/// PartialEq always returns false since we can't meaningfully compare docs.
205205+#[derive(Clone)]
206206+pub struct LoadedDocState {
207207+ /// The Loro document with all content already loaded/merged.
208208+ pub doc: LoroDoc,
209209+ /// StrongRef to the entry if editing an existing record.
210210+ pub entry_ref: Option<StrongRef<'static>>,
211211+ /// StrongRef to the sh.weaver.edit.root record (for PDS sync).
212212+ pub edit_root: Option<StrongRef<'static>>,
213213+ /// StrongRef to the most recent sh.weaver.edit.diff record.
214214+ pub last_diff: Option<StrongRef<'static>>,
215215+ /// Whether the current doc state is synced with PDS.
216216+ /// False if local has changes not yet pushed to PDS.
217217+ pub is_synced: bool,
218218+}
219219+220220+impl PartialEq for LoadedDocState {
221221+ fn eq(&self, _other: &Self) -> bool {
222222+ // LoadedDocState contains LoroDoc which can't be meaningfully compared.
223223+ // Return false to ensure components re-render when passed as props.
224224+ false
225225+ }
226226+}
227227+197228impl EditorDocument {
198229 /// Check if a character position is within the block-syntax zone of its line.
199230 fn is_in_block_syntax_zone(&self, pos: usize) -> bool {
···787818 self.last_diff.set(diff);
788819 }
789820790790- /// Check if there are unsynchronized changes since the last sync.
791791- pub fn has_unsync_changes(&self) -> bool {
821821+ /// Check if there are unsynced changes since the last PDS sync.
822822+ pub fn has_unsynced_changes(&self) -> bool {
792823 match &self.last_synced_version {
793824 Some(synced_vv) => self.doc.oplog_vv() != *synced_vv,
794825 None => true, // Never synced, so there are changes
···921952 undo_mgr: Rc::new(RefCell::new(undo_mgr)),
922953 loro_cursor,
923954 // Reactive editor state - wrapped in Signals
955955+ cursor: Signal::new(cursor_state),
956956+ selection: Signal::new(None),
957957+ composition: Signal::new(None),
958958+ composition_ended_at: Signal::new(None),
959959+ last_edit: Signal::new(None),
960960+ pending_snap: Signal::new(None),
961961+ }
962962+ }
963963+964964+ /// Create an EditorDocument from pre-loaded state.
965965+ ///
966966+ /// Use this when loading from PDS/localStorage merge outside reactive context.
967967+ /// The `LoadedDocState` contains a pre-merged LoroDoc; this method wraps it
968968+ /// with the reactive Signals needed for the editor UI.
969969+ ///
970970+ /// # Note
971971+ /// This creates Dioxus Signals. Call from within a component using `use_hook`.
972972+ pub fn from_loaded_state(state: LoadedDocState) -> Self {
973973+ let doc = state.doc;
974974+975975+ // Get all containers from the loaded doc
976976+ let content = doc.get_text("content");
977977+ let title = doc.get_text("title");
978978+ let path = doc.get_text("path");
979979+ let created_at = doc.get_text("created_at");
980980+ let tags = doc.get_list("tags");
981981+ let embeds = doc.get_map("embeds");
982982+983983+ // Set up undo manager
984984+ let mut undo_mgr = UndoManager::new(&doc);
985985+ undo_mgr.set_merge_interval(300);
986986+ undo_mgr.set_max_undo_steps(100);
987987+988988+ // Position cursor at end of content
989989+ let cursor_offset = content.len_unicode();
990990+ let cursor_state = CursorState {
991991+ offset: cursor_offset,
992992+ affinity: Affinity::Before,
993993+ };
994994+ let loro_cursor = content.get_cursor(cursor_offset, Side::default());
995995+996996+ // Track sync state - if synced, record current version
997997+ let last_synced_version = if state.is_synced {
998998+ Some(doc.oplog_vv())
999999+ } else {
10001000+ None
10011001+ };
10021002+10031003+ Self {
10041004+ doc,
10051005+ content,
10061006+ title,
10071007+ path,
10081008+ created_at,
10091009+ tags,
10101010+ embeds,
10111011+ entry_ref: Signal::new(state.entry_ref),
10121012+ edit_root: Signal::new(state.edit_root),
10131013+ last_diff: Signal::new(state.last_diff),
10141014+ last_synced_version,
10151015+ undo_mgr: Rc::new(RefCell::new(undo_mgr)),
10161016+ loro_cursor,
9241017 cursor: Signal::new(cursor_state),
9251018 selection: Signal::new(None),
9261019 composition: Signal::new(None),
···146146 Some(doc)
147147}
148148149149+/// Data loaded from localStorage snapshot.
150150+pub struct LocalSnapshotData {
151151+ /// The raw CRDT snapshot bytes
152152+ pub snapshot: Vec<u8>,
153153+ /// Entry StrongRef if editing an existing entry
154154+ pub entry_ref: Option<StrongRef<'static>>,
155155+}
156156+157157+/// Load snapshot data from LocalStorage (WASM only).
158158+///
159159+/// Unlike `load_from_storage`, this doesn't create an EditorDocument and is safe
160160+/// to call outside of reactive context. Use with `load_and_merge_document`.
161161+///
162162+/// # Arguments
163163+/// * `key` - Storage key (e.g., "new:abc123" for new entries, or AT-URI for existing)
164164+#[cfg(all(target_family = "wasm", target_os = "unknown"))]
165165+pub fn load_snapshot_from_storage(key: &str) -> Option<LocalSnapshotData> {
166166+ let snapshot: EditorSnapshot = LocalStorage::get(storage_key(key)).ok()?;
167167+168168+ // Try to get CRDT snapshot bytes
169169+ let snapshot_bytes = snapshot
170170+ .snapshot
171171+ .as_ref()
172172+ .and_then(|b64| BASE64.decode(b64).ok())?;
173173+174174+ // Try to reconstruct entry_ref from stored URI + CID
175175+ let entry_ref = snapshot
176176+ .editing_uri
177177+ .as_ref()
178178+ .zip(snapshot.editing_cid.as_ref())
179179+ .and_then(|(uri_str, cid_str)| {
180180+ let uri = AtUri::new(uri_str).ok()?.into_static();
181181+ let cid = Cid::new(cid_str.as_bytes()).ok()?.into_static();
182182+ Some(StrongRef::new().uri(uri).cid(cid).build())
183183+ });
184184+185185+ Some(LocalSnapshotData {
186186+ snapshot: snapshot_bytes,
187187+ entry_ref,
188188+ })
189189+}
190190+191191+/// Load snapshot data from LocalStorage (non-WASM stub).
192192+#[cfg(not(all(target_family = "wasm", target_os = "unknown")))]
193193+pub fn load_snapshot_from_storage(_key: &str) -> Option<LocalSnapshotData> {
194194+ None
195195+}
196196+149197/// Delete a draft from LocalStorage (WASM only).
150198///
151199/// # Arguments
+305-48
crates/weaver-app/src/components/editor/sync.rs
···18181919use std::collections::BTreeMap;
20202121+use jacquard::bytes::Bytes;
2122use jacquard::cowstr::ToCowStr;
2223use jacquard::prelude::*;
2324use jacquard::types::blob::MimeType;
···40414142use crate::fetch::Fetcher;
42434343-use super::document::EditorDocument;
4444+use super::document::{EditorDocument, LoadedDocState};
4545+use loro::LoroDoc;
44464547const ROOT_NSID: &str = "sh.weaver.edit.root";
4648const DIFF_NSID: &str = "sh.weaver.edit.diff";
···274276 .await
275277 .ok_or_else(|| WeaverError::InvalidNotebook("Not authenticated".into()))?;
276278277277- // Upload updates blob
278278- let mime_type = MimeType::new_static("application/octet-stream");
279279- let blob_ref = client
280280- .upload_blob(updates, mime_type)
281281- .await
282282- .map_err(|e| WeaverError::InvalidNotebook(format!("Failed to upload diff: {}", e)))?;
279279+ // Threshold for inline vs blob storage (8KB max for inline per lexicon)
280280+ const INLINE_THRESHOLD: usize = 8192;
281281+282282+ // Use inline for small diffs, blob for larger ones
283283+ let (blob_ref, inline_diff): (Option<jacquard::types::blob::BlobRef<'static>>, _) =
284284+ if updates.len() <= INLINE_THRESHOLD {
285285+ tracing::debug!("Using inline diff ({} bytes)", updates.len());
286286+ (None, Some(jacquard::bytes::Bytes::from(updates)))
287287+ } else {
288288+ tracing::debug!("Using blob diff ({} bytes)", updates.len());
289289+ let mime_type = MimeType::new_static("application/octet-stream");
290290+ let blob = client.upload_blob(updates, mime_type).await.map_err(|e| {
291291+ WeaverError::InvalidNotebook(format!("Failed to upload diff: {}", e))
292292+ })?;
293293+ (Some(blob.into()), None)
294294+ };
283295284296 // Build DocRef - use EntryRef if published, DraftRef if not
285297 let doc_ref = build_doc_ref(draft_key, entry_uri, entry_cid);
···302314 let diff = Diff::new()
303315 .doc(doc_ref)
304316 .root(root_ref)
305305- .snapshot(blob_ref)
317317+ .maybe_snapshot(blob_ref)
318318+ .maybe_inline_diff(inline_diff)
306319 .maybe_prev(prev_ref)
307320 .build();
308321···355368 draft_key: &str,
356369) -> Result<SyncResult, WeaverError> {
357370 // Check if we have changes to sync
358358- if !doc.has_unsync_changes() {
371371+ if !doc.has_unsynced_changes() {
359372 return Ok(SyncResult::NoChanges);
360373 }
361374···434447 /// The latest diff reference (if any diffs exist)
435448 pub last_diff_ref: Option<StrongRef<'static>>,
436449 /// The Loro snapshot bytes from the root
437437- pub root_snapshot: Vec<u8>,
450450+ pub root_snapshot: Bytes,
438451 /// All diff update bytes in order (oldest first, by TID)
439439- pub diff_updates: Vec<Vec<u8>>,
452452+ pub diff_updates: Vec<Bytes>,
440453}
441454442455/// Fetch a blob from the PDS.
443443-async fn fetch_blob(
444444- fetcher: &Fetcher,
445445- did: &Did<'_>,
446446- cid: &Cid<'_>,
447447-) -> Result<Vec<u8>, WeaverError> {
456456+async fn fetch_blob(fetcher: &Fetcher, did: &Did<'_>, cid: &Cid<'_>) -> Result<Bytes, WeaverError> {
448457 let pds_url = fetcher
449458 .client
450459 .pds_for_did(did)
···464473 WeaverError::InvalidNotebook(format!("Failed to parse blob response: {}", e))
465474 })?;
466475467467- Ok(output.body.to_vec())
476476+ Ok(output.body)
468477}
469478470479/// Load edit state from the PDS for an entry.
···581590 );
582591 }
583592584584- // Fetch all diff blobs in TID order (BTreeMap iterates in sorted order)
593593+ // Fetch all diff data in TID order (BTreeMap iterates in sorted order)
594594+ // Diffs can be stored either inline or as blobs
585595 let mut diff_updates = Vec::new();
586596 let mut last_diff_ref = None;
587597588598 for (_rkey, (diff, cid, uri)) in &diffs_by_rkey {
589589- let blob_bytes = fetch_blob(fetcher, &root_id.did(), diff.snapshot.blob().cid()).await?;
590590- diff_updates.push(blob_bytes);
599599+ // Check for inline diff first, then fall back to blob
600600+ let diff_bytes = if let Some(ref inline) = diff.inline_diff {
601601+ inline.clone()
602602+ } else if let Some(ref snapshot) = diff.snapshot {
603603+ fetch_blob(fetcher, &root_id.did(), snapshot.blob().cid()).await?
604604+ } else {
605605+ tracing::warn!("Diff has neither inline_diff nor snapshot, skipping");
606606+ continue;
607607+ };
608608+609609+ diff_updates.push(diff_bytes);
591610592611 // Track the last diff (will be the one with highest TID after iteration)
593612 last_diff_ref = Some(StrongRef::new().uri(uri.clone()).cid(cid.clone()).build());
···601620 }))
602621}
603622604604-/// Load an EditorDocument by merging local storage and PDS state.
623623+/// Load document state by merging local storage and PDS state.
605624///
606625/// This is the main entry point for loading a document with full sync support.
607626/// It:
···609628/// 2. Loads from PDS (if available)
610629/// 3. Merges both using Loro's CRDT merge
611630///
612612-/// The result is a document with all changes from both sources.
631631+/// The result is a `LoadedDocState` containing a pre-merged LoroDoc that can be
632632+/// converted to an EditorDocument inside a reactive context using `use_hook`.
613633///
614634/// # Arguments
615635/// * `fetcher` - The authenticated fetcher
···617637/// * `entry_uri` - Optional AT-URI if editing an existing entry
618638///
619639/// # Returns
620620-/// A merged EditorDocument, or None if no state exists anywhere.
640640+/// A `LoadedDocState` with merged state, or None if no state exists anywhere.
621641pub async fn load_and_merge_document(
622642 fetcher: &Fetcher,
623643 draft_key: &str,
624644 entry_uri: Option<&AtUri<'_>>,
625625-) -> Result<Option<EditorDocument>, WeaverError> {
626626- use super::storage::load_from_storage;
645645+) -> Result<Option<LoadedDocState>, WeaverError> {
646646+ use super::storage::load_snapshot_from_storage;
627647628628- // Load from localStorage
629629- let local_doc = load_from_storage(draft_key);
648648+ // Load snapshot + entry_ref from localStorage
649649+ let local_data = load_snapshot_from_storage(draft_key);
630650631651 // Load from PDS (only if we have an entry URI)
632652 let pds_state = if let Some(uri) = entry_uri {
···635655 None
636656 };
637657638638- match (local_doc, pds_state) {
658658+ match (local_data, pds_state) {
639659 (None, None) => Ok(None),
640660641641- (Some(doc), None) => {
642642- // Only local state exists
661661+ (Some(local), None) => {
662662+ // Only local state exists - build LoroDoc from snapshot
643663 tracing::debug!("Loaded document from localStorage only");
644644- Ok(Some(doc))
664664+ let doc = LoroDoc::new();
665665+ if let Err(e) = doc.import(&local.snapshot) {
666666+ tracing::warn!("Failed to import local snapshot: {:?}", e);
667667+ }
668668+669669+ Ok(Some(LoadedDocState {
670670+ doc,
671671+ entry_ref: local.entry_ref, // Restored from localStorage
672672+ edit_root: None,
673673+ last_diff: None,
674674+ is_synced: false, // Local-only, not synced to PDS
675675+ }))
645676 }
646677647678 (None, Some(pds)) => {
648679 // Only PDS state exists - reconstruct from snapshot + diffs
649680 tracing::debug!("Loaded document from PDS only");
650650- let mut doc = EditorDocument::from_snapshot(&pds.root_snapshot, None, 0);
681681+ let doc = LoroDoc::new();
682682+683683+ // Import root snapshot
684684+ if let Err(e) = doc.import(&pds.root_snapshot) {
685685+ tracing::warn!("Failed to import PDS root snapshot: {:?}", e);
686686+ }
651687652688 // Apply all diffs in order
653689 for updates in &pds.diff_updates {
654654- if let Err(e) = doc.import_updates(updates) {
690690+ if let Err(e) = doc.import(updates) {
655691 tracing::warn!("Failed to apply diff update: {:?}", e);
656692 }
657693 }
658694659659- // Set sync state so we don't re-upload what we just downloaded
660660- doc.set_synced_from_pds(pds.root_ref, pds.last_diff_ref);
661661-662662- Ok(Some(doc))
695695+ Ok(Some(LoadedDocState {
696696+ doc,
697697+ entry_ref: None, // Entry ref comes from the entry itself, not edit state
698698+ edit_root: Some(pds.root_ref),
699699+ last_diff: pds.last_diff_ref,
700700+ is_synced: true, // Just loaded from PDS, fully synced
701701+ }))
663702 }
664703665665- (Some(mut local_doc), Some(pds)) => {
704704+ (Some(local), Some(pds)) => {
666705 // Both exist - merge using CRDT
667706 tracing::debug!("Merging document from localStorage and PDS");
668707669669- // Import PDS root snapshot into local doc
670670- // Loro will automatically merge concurrent changes
671671- if let Err(e) = local_doc.import_updates(&pds.root_snapshot) {
708708+ let doc = LoroDoc::new();
709709+710710+ // Import local snapshot first
711711+ if let Err(e) = doc.import(&local.snapshot) {
712712+ tracing::warn!("Failed to import local snapshot: {:?}", e);
713713+ }
714714+715715+ // Import PDS root snapshot - Loro will merge
716716+ if let Err(e) = doc.import(&pds.root_snapshot) {
672717 tracing::warn!("Failed to merge PDS root snapshot: {:?}", e);
673718 }
674719675720 // Import all diffs
676721 for updates in &pds.diff_updates {
677677- if let Err(e) = local_doc.import_updates(updates) {
722722+ if let Err(e) = doc.import(updates) {
678723 tracing::warn!("Failed to merge PDS diff: {:?}", e);
679724 }
680725 }
681726682682- // Update sync state
683683- // We keep the PDS root/diff refs since that's where we'll push updates
684684- local_doc.set_edit_root(Some(pds.root_ref));
685685- local_doc.set_last_diff(pds.last_diff_ref);
686686- // Don't call set_synced_from_pds - local changes still need syncing
727727+ Ok(Some(LoadedDocState {
728728+ doc,
729729+ entry_ref: local.entry_ref, // Restored from localStorage
730730+ edit_root: Some(pds.root_ref),
731731+ last_diff: pds.last_diff_ref,
732732+ is_synced: false, // Local had state, may have unsynced changes
733733+ }))
734734+ }
735735+ }
736736+}
737737+738738+// ============================================================================
739739+// Sync UI Components
740740+// ============================================================================
741741+742742+use crate::auth::AuthState;
743743+use dioxus::prelude::*;
744744+745745+/// Sync status states for UI display.
746746+#[derive(Clone, Copy, PartialEq, Eq, Debug)]
747747+pub enum SyncState {
748748+ /// All local changes have been synced to PDS
749749+ Synced,
750750+ /// Currently syncing to PDS
751751+ Syncing,
752752+ /// Has local changes not yet synced
753753+ Unsynced,
754754+ /// Last sync failed
755755+ Error,
756756+ /// Not authenticated or sync disabled
757757+ Disabled,
758758+}
759759+760760+/// Props for the SyncStatus component.
761761+#[derive(Props, Clone, PartialEq)]
762762+pub struct SyncStatusProps {
763763+ /// The editor document to sync
764764+ pub document: EditorDocument,
765765+ /// Draft key for this document
766766+ pub draft_key: String,
767767+ /// Auto-sync interval in milliseconds (0 to disable)
768768+ #[props(default = 30_000)]
769769+ pub auto_sync_interval_ms: u32,
770770+}
771771+772772+/// Sync status indicator with auto-sync functionality.
773773+///
774774+/// Displays the current sync state and automatically syncs to PDS periodically.
775775+#[component]
776776+pub fn SyncStatus(props: SyncStatusProps) -> Element {
777777+ let fetcher = use_context::<Fetcher>();
778778+ let auth_state = use_context::<Signal<AuthState>>();
687779688688- Ok(Some(local_doc))
780780+ // Sync state management
781781+ let mut sync_state = use_signal(|| {
782782+ if props.document.has_unsynced_changes() {
783783+ SyncState::Unsynced
784784+ } else {
785785+ SyncState::Synced
786786+ }
787787+ });
788788+ let mut last_error: Signal<Option<String>> = use_signal(|| None);
789789+790790+ let doc = props.document.clone();
791791+ let draft_key = props.draft_key.clone();
792792+793793+ // Check if we're authenticated and have an entry to sync
794794+ let is_authenticated = auth_state.read().is_authenticated();
795795+ let has_entry = doc.entry_ref().is_some();
796796+797797+ // Auto-sync trigger signal - set to true to trigger a sync
798798+ let mut trigger_sync = use_signal(|| false);
799799+800800+ // Auto-sync timer (WASM only) - just sets the trigger, doesn't access signals directly
801801+ #[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
802802+ {
803803+ let auto_sync_interval = props.auto_sync_interval_ms;
804804+ let doc_for_check = doc.clone();
805805+806806+ use_effect(move || {
807807+ if auto_sync_interval == 0 {
808808+ return;
809809+ }
810810+811811+ let doc = doc_for_check.clone();
812812+813813+ let interval = gloo_timers::callback::Interval::new(auto_sync_interval, move || {
814814+ // Only trigger if there are unsynced changes and we're not already syncing
815815+ if doc.has_unsynced_changes() {
816816+ // This just sets a signal - the actual sync happens in use_future below
817817+ trigger_sync.set(true);
818818+ }
819819+ });
820820+821821+ interval.forget();
822822+ });
823823+ }
824824+825825+ #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
826826+ let mut trigger_sync = use_signal(|| false);
827827+828828+ // Update sync state when document changes
829829+ // Note: We use peek() to avoid creating a reactive dependency on sync_state
830830+ let doc_for_effect = doc.clone();
831831+ use_effect(move || {
832832+ // Check for unsynced changes (reads last_edit signal for reactivity)
833833+ let _edit = doc_for_effect.last_edit();
834834+835835+ // Use peek to avoid reactive loop
836836+ let current_state = *sync_state.peek();
837837+ if current_state != SyncState::Syncing {
838838+ if doc_for_effect.has_unsynced_changes() && current_state != SyncState::Unsynced {
839839+ sync_state.set(SyncState::Unsynced);
840840+ }
841841+ }
842842+ });
843843+844844+ // Sync effect - watches trigger_sync and performs sync when triggered
845845+ let doc_for_sync = doc.clone();
846846+ let draft_key_for_sync = draft_key.clone();
847847+ let fetcher_for_sync = fetcher.clone();
848848+849849+ let doc_for_check = doc.clone();
850850+ use_effect(move || {
851851+ // Read trigger to create reactive dependency
852852+ let should_sync = *trigger_sync.read();
853853+854854+ if !should_sync {
855855+ return;
856856+ }
857857+858858+ // Reset trigger immediately
859859+ trigger_sync.set(false);
860860+861861+ // Check if already syncing
862862+ if *sync_state.peek() == SyncState::Syncing {
863863+ return;
864864+ }
865865+866866+ // Check if authenticated and has entry
867867+ if !is_authenticated || !has_entry {
868868+ return;
869869+ }
870870+871871+ // Check if there are actually changes to sync
872872+ if !doc_for_check.has_unsynced_changes() {
873873+ // Already synced, just update state
874874+ sync_state.set(SyncState::Synced);
875875+ return;
876876+ }
877877+878878+ sync_state.set(SyncState::Syncing);
879879+880880+ let mut doc = doc_for_sync.clone();
881881+ let draft_key = draft_key_for_sync.clone();
882882+ let fetcher = fetcher_for_sync.clone();
883883+884884+ // Spawn the async work
885885+ spawn(async move {
886886+ match sync_to_pds(&fetcher, &mut doc, &draft_key).await {
887887+ Ok(SyncResult::NoChanges) => {
888888+ // No changes to sync - already up to date
889889+ sync_state.set(SyncState::Synced);
890890+ last_error.set(None);
891891+ tracing::debug!("No changes to sync");
892892+ }
893893+ Ok(_) => {
894894+ sync_state.set(SyncState::Synced);
895895+ last_error.set(None);
896896+ tracing::debug!("Sync completed successfully");
897897+ }
898898+ Err(e) => {
899899+ sync_state.set(SyncState::Error);
900900+ last_error.set(Some(e.to_string()));
901901+ tracing::warn!("Sync failed: {}", e);
902902+ }
903903+ }
904904+ });
905905+ });
906906+907907+ // Manual sync handler - just sets the trigger if there are changes
908908+ let doc_for_manual = doc.clone();
909909+ let on_manual_sync = move |_| {
910910+ if *sync_state.peek() == SyncState::Syncing {
911911+ return; // Already syncing
912912+ }
913913+ if !doc_for_manual.has_unsynced_changes() {
914914+ // Already synced
915915+ sync_state.set(SyncState::Synced);
916916+ return;
917917+ }
918918+ trigger_sync.set(true);
919919+ };
920920+921921+ // Determine display state
922922+ let display_state = if !is_authenticated {
923923+ SyncState::Disabled
924924+ } else if !has_entry {
925925+ SyncState::Disabled // Can't sync unpublished entries
926926+ } else {
927927+ *sync_state.read()
928928+ };
929929+930930+ let (icon, label, class) = match display_state {
931931+ SyncState::Synced => ("✓", "Synced", "sync-status synced"),
932932+ SyncState::Syncing => ("◌", "Syncing...", "sync-status syncing"),
933933+ SyncState::Unsynced => ("●", "Unsynced", "sync-status unsynced"),
934934+ SyncState::Error => ("✕", "Sync error", "sync-status error"),
935935+ SyncState::Disabled => ("○", "Sync disabled", "sync-status disabled"),
936936+ };
937937+938938+ rsx! {
939939+ div {
940940+ class: "{class}",
941941+ title: if let Some(ref err) = *last_error.read() { err.clone() } else { label.to_string() },
942942+ onclick: on_manual_sync,
943943+944944+ span { class: "sync-icon", "{icon}" }
945945+ span { class: "sync-label", "{label}" }
689946 }
690947 }
691948}
+7-1
lexicons/edit/diff.json
···88 "key": "tid",
99 "record": {
1010 "type": "object",
1111- "required": ["snapshot", "root", "doc"],
1111+ "required": ["root", "doc"],
1212 "properties": {
1313 "snapshot": {
1414 "type": "blob",
1515+ "description": "Diff from previous diff. Either this or inlineDiff must be present to be valid",
1516 "accept": ["*/*"],
1617 "maxSize": 3000000
1818+ },
1919+ "inlineDiff": {
2020+ "type": "bytes",
2121+ "description": "An inline diff for for small edit batches. Either this or snapshot must be present to be valid",
2222+ "maxLength": 8192
1723 },
1824 "root": {
1925 "type": "ref",