From 0c330aaa4f1ba33da56071c6df97400cb1523727 Mon Sep 17 00:00:00 2001 From: Orual Date: Thu, 27 Nov 2025 21:37:40 -0500 Subject: [PATCH] sync works. --- .../lexicons/sh_weaver_edit_diff.json | 7 +- crates/weaver-api/src/sh_weaver/edit/diff.rs | 97 +++-- crates/weaver-app/assets/styling/editor.css | 83 +++- .../src/components/editor/component.rs | 185 +++++---- .../src/components/editor/document.rs | 97 ++++- .../weaver-app/src/components/editor/mod.rs | 11 +- .../src/components/editor/storage.rs | 48 +++ .../weaver-app/src/components/editor/sync.rs | 353 +++++++++++++++--- lexicons/edit/diff.json | 8 +- 9 files changed, 729 insertions(+), 160 deletions(-) diff --git a/crates/weaver-api/lexicons/sh_weaver_edit_diff.json b/crates/weaver-api/lexicons/sh_weaver_edit_diff.json index 8b3696c..683833d 100644 --- a/crates/weaver-api/lexicons/sh_weaver_edit_diff.json +++ b/crates/weaver-api/lexicons/sh_weaver_edit_diff.json @@ -9,7 +9,6 @@ "record": { "type": "object", "required": [ - "snapshot", "root", "doc" ], @@ -18,6 +17,11 @@ "type": "ref", "ref": "sh.weaver.edit.defs#docRef" }, + "inlineDiff": { + "type": "bytes", + "description": "An inline diff for for small edit batches. Either this or snapshot must be present to be valid", + "maxLength": 8192 + }, "prev": { "type": "ref", "ref": "com.atproto.repo.strongRef" @@ -28,6 +32,7 @@ }, "snapshot": { "type": "blob", + "description": "Diff from previous diff. Either this or inlineDiff must be present to be valid", "accept": [ "*/*" ], diff --git a/crates/weaver-api/src/sh_weaver/edit/diff.rs b/crates/weaver-api/src/sh_weaver/edit/diff.rs index a7c7e1c..9937883 100644 --- a/crates/weaver-api/src/sh_weaver/edit/diff.rs +++ b/crates/weaver-api/src/sh_weaver/edit/diff.rs @@ -20,13 +20,18 @@ pub struct Diff<'a> { #[serde(borrow)] pub doc: crate::sh_weaver::edit::DocRef<'a>, + /// An inline diff for for small edit batches. Either this or snapshot must be present to be valid + #[serde(skip_serializing_if = "std::option::Option::is_none")] + pub inline_diff: std::option::Option, #[serde(skip_serializing_if = "std::option::Option::is_none")] #[serde(borrow)] pub prev: std::option::Option>, #[serde(borrow)] pub root: crate::com_atproto::repo::strong_ref::StrongRef<'a>, + /// Diff from previous diff. Either this or inlineDiff must be present to be valid + #[serde(skip_serializing_if = "std::option::Option::is_none")] #[serde(borrow)] - pub snapshot: jacquard_common::types::blob::BlobRef<'a>, + pub snapshot: std::option::Option>, } pub mod diff_state { @@ -39,7 +44,6 @@ pub mod diff_state { } /// State trait tracking which required fields have been set pub trait State: sealed::Sealed { - type Snapshot; type Root; type Doc; } @@ -47,23 +51,13 @@ pub mod diff_state { pub struct Empty(()); impl sealed::Sealed for Empty {} impl State for Empty { - type Snapshot = Unset; type Root = Unset; type Doc = Unset; } - ///State transition - sets the `snapshot` field to Set - pub struct SetSnapshot(PhantomData S>); - impl sealed::Sealed for SetSnapshot {} - impl State for SetSnapshot { - type Snapshot = Set; - type Root = S::Root; - type Doc = S::Doc; - } ///State transition - sets the `root` field to Set pub struct SetRoot(PhantomData S>); impl sealed::Sealed for SetRoot {} impl State for SetRoot { - type Snapshot = S::Snapshot; type Root = Set; type Doc = S::Doc; } @@ -71,15 +65,12 @@ pub mod diff_state { pub struct SetDoc(PhantomData S>); impl sealed::Sealed for SetDoc {} impl State for SetDoc { - type Snapshot = S::Snapshot; type Root = S::Root; type Doc = Set; } /// Marker types for field names #[allow(non_camel_case_types)] pub mod members { - ///Marker type for the `snapshot` field - pub struct snapshot(()); ///Marker type for the `root` field pub struct root(()); ///Marker type for the `doc` field @@ -92,6 +83,7 @@ pub struct DiffBuilder<'a, S: diff_state::State> { _phantom_state: ::core::marker::PhantomData S>, __unsafe_private_named: ( ::core::option::Option>, + ::core::option::Option, ::core::option::Option>, ::core::option::Option>, ::core::option::Option>, @@ -111,7 +103,7 @@ impl<'a> DiffBuilder<'a, diff_state::Empty> { pub fn new() -> Self { DiffBuilder { _phantom_state: ::core::marker::PhantomData, - __unsafe_private_named: (None, None, None, None), + __unsafe_private_named: (None, None, None, None, None), _phantom: ::core::marker::PhantomData, } } @@ -136,13 +128,26 @@ where } } +impl<'a, S: diff_state::State> DiffBuilder<'a, S> { + /// Set the `inlineDiff` field (optional) + pub fn inline_diff(mut self, value: impl Into>) -> Self { + self.__unsafe_private_named.1 = value.into(); + self + } + /// Set the `inlineDiff` field to an Option value (optional) + pub fn maybe_inline_diff(mut self, value: Option) -> Self { + self.__unsafe_private_named.1 = value; + self + } +} + impl<'a, S: diff_state::State> DiffBuilder<'a, S> { /// Set the `prev` field (optional) pub fn prev( mut self, value: impl Into>>, ) -> Self { - self.__unsafe_private_named.1 = value.into(); + self.__unsafe_private_named.2 = value.into(); self } /// Set the `prev` field to an Option value (optional) @@ -150,7 +155,7 @@ impl<'a, S: diff_state::State> DiffBuilder<'a, S> { mut self, value: Option>, ) -> Self { - self.__unsafe_private_named.1 = value; + self.__unsafe_private_named.2 = value; self } } @@ -165,7 +170,7 @@ where mut self, value: impl Into>, ) -> DiffBuilder<'a, diff_state::SetRoot> { - self.__unsafe_private_named.2 = ::core::option::Option::Some(value.into()); + self.__unsafe_private_named.3 = ::core::option::Option::Some(value.into()); DiffBuilder { _phantom_state: ::core::marker::PhantomData, __unsafe_private_named: self.__unsafe_private_named, @@ -174,29 +179,28 @@ where } } -impl<'a, S> DiffBuilder<'a, S> -where - S: diff_state::State, - S::Snapshot: diff_state::IsUnset, -{ - /// Set the `snapshot` field (required) +impl<'a, S: diff_state::State> DiffBuilder<'a, S> { + /// Set the `snapshot` field (optional) pub fn snapshot( mut self, - value: impl Into>, - ) -> DiffBuilder<'a, diff_state::SetSnapshot> { - self.__unsafe_private_named.3 = ::core::option::Option::Some(value.into()); - DiffBuilder { - _phantom_state: ::core::marker::PhantomData, - __unsafe_private_named: self.__unsafe_private_named, - _phantom: ::core::marker::PhantomData, - } + value: impl Into>>, + ) -> Self { + self.__unsafe_private_named.4 = value.into(); + self + } + /// Set the `snapshot` field to an Option value (optional) + pub fn maybe_snapshot( + mut self, + value: Option>, + ) -> Self { + self.__unsafe_private_named.4 = value; + self } } impl<'a, S> DiffBuilder<'a, S> where S: diff_state::State, - S::Snapshot: diff_state::IsSet, S::Root: diff_state::IsSet, S::Doc: diff_state::IsSet, { @@ -204,9 +208,10 @@ where pub fn build(self) -> Diff<'a> { Diff { doc: self.__unsafe_private_named.0.unwrap(), - prev: self.__unsafe_private_named.1, - root: self.__unsafe_private_named.2.unwrap(), - snapshot: self.__unsafe_private_named.3.unwrap(), + inline_diff: self.__unsafe_private_named.1, + prev: self.__unsafe_private_named.2, + root: self.__unsafe_private_named.3.unwrap(), + snapshot: self.__unsafe_private_named.4, extra_data: Default::default(), } } @@ -220,9 +225,10 @@ where ) -> Diff<'a> { Diff { doc: self.__unsafe_private_named.0.unwrap(), - prev: self.__unsafe_private_named.1, - root: self.__unsafe_private_named.2.unwrap(), - snapshot: self.__unsafe_private_named.3.unwrap(), + inline_diff: self.__unsafe_private_named.1, + prev: self.__unsafe_private_named.2, + root: self.__unsafe_private_named.3.unwrap(), + snapshot: self.__unsafe_private_named.4, extra_data: Some(extra_data), } } @@ -329,7 +335,6 @@ fn lexicon_doc_sh_weaver_edit_diff() -> ::jacquard_lexicon::lexicon::LexiconDoc< description: None, required: Some( vec![ - ::jacquard_common::smol_str::SmolStr::new_static("snapshot"), ::jacquard_common::smol_str::SmolStr::new_static("root"), ::jacquard_common::smol_str::SmolStr::new_static("doc") ], @@ -347,6 +352,16 @@ fn lexicon_doc_sh_weaver_edit_diff() -> ::jacquard_lexicon::lexicon::LexiconDoc< ), }), ); + map.insert( + ::jacquard_common::smol_str::SmolStr::new_static( + "inlineDiff", + ), + ::jacquard_lexicon::lexicon::LexObjectProperty::Bytes(::jacquard_lexicon::lexicon::LexBytes { + description: None, + max_length: Some(8192usize), + min_length: None, + }), + ); map.insert( ::jacquard_common::smol_str::SmolStr::new_static("prev"), ::jacquard_lexicon::lexicon::LexObjectProperty::Ref(::jacquard_lexicon::lexicon::LexRef { diff --git a/crates/weaver-app/assets/styling/editor.css b/crates/weaver-app/assets/styling/editor.css index 64ccbaf..f0884d1 100644 --- a/crates/weaver-app/assets/styling/editor.css +++ b/crates/weaver-app/assets/styling/editor.css @@ -389,6 +389,15 @@ li:has(.md-syntax-block:not(.hidden)) { } /* Publish button and dialog - matches report dialog theming */ +/* Actions container for sync status + publish button */ +.meta-actions { + display: flex; + align-items: center; + gap: 12px; + margin-left: auto; + flex-shrink: 0; +} + .publish-button { padding: 0.5rem 1rem; background: var(--color-primary); @@ -398,8 +407,6 @@ li:has(.md-syntax-block:not(.hidden)) { cursor: pointer; font-weight: 500; font-family: var(--font-body); - margin-left: auto; - flex-shrink: 0; } .publish-button:hover:not(:disabled) { @@ -613,3 +620,75 @@ li:has(.md-syntax-block:not(.hidden)) { justify-content: flex-end; margin-top: 1rem; } + +/* Sync status indicator */ +.sync-status { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 10px; + border-radius: 12px; + font-size: 12px; + cursor: pointer; + transition: background 0.2s ease, opacity 0.2s ease; + user-select: none; +} + +.sync-status:hover { + opacity: 0.8; +} + +.sync-status .sync-icon { + font-size: 10px; + line-height: 1; +} + +.sync-status .sync-label { + font-weight: 500; +} + +/* Synced state - subtle success */ +.sync-status.synced { + background: color-mix(in srgb, var(--color-success) 15%, transparent); + color: var(--color-success); +} + +/* Syncing state - in progress */ +.sync-status.syncing { + background: color-mix(in srgb, var(--color-warning) 15%, transparent); + color: var(--color-warning); + cursor: wait; +} + +.sync-status.syncing .sync-icon { + animation: spin 1s linear infinite; +} + +@keyframes spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} + +/* Unsynced state - has pending changes */ +.sync-status.unsynced { + background: color-mix(in srgb, var(--color-warning) 20%, transparent); + color: var(--color-warning); +} + +/* Error state */ +.sync-status.error { + background: color-mix(in srgb, var(--color-error) 15%, transparent); + color: var(--color-error); +} + +/* Disabled state */ +.sync-status.disabled { + background: var(--color-overlay); + color: var(--color-muted); + cursor: default; + opacity: 0.6; +} + +.sync-status.disabled:hover { + opacity: 0.6; +} diff --git a/crates/weaver-app/src/components/editor/component.rs b/crates/weaver-app/src/components/editor/component.rs index 13767fa..7734fb7 100644 --- a/crates/weaver-app/src/components/editor/component.rs +++ b/crates/weaver-app/src/components/editor/component.rs @@ -23,22 +23,31 @@ use super::input::{ use super::offset_map::SnapDirection; use super::paragraph::ParagraphRender; use super::platform; +use super::document::LoadedDocState; use super::publish::{LoadedEntry, PublishButton, load_entry_for_editing}; use super::render; use super::storage; +use super::sync::{SyncStatus, load_and_merge_document}; use super::toolbar::EditorToolbar; use super::visibility::update_syntax_visibility; use super::writer::{EditorImageResolver, SyntaxSpanInfo}; -/// Result of loading an entry - either loaded, failed, or not needed. -#[derive(Clone, PartialEq)] +/// Result of loading document state. enum LoadResult { - Loaded(LoadedEntry), - Failed, - NotNeeded, + /// Document state loaded (may be merged from PDS + localStorage) + Loaded(LoadedDocState), + /// Loading failed + Failed(String), + /// Still loading + Loading, } -/// Wrapper component that handles loading an existing entry before rendering the editor. +/// Wrapper component that handles loading document state before rendering the editor. +/// +/// Loads and merges state from: +/// - localStorage (local CRDT snapshot) +/// - PDS edit state (if editing published entry) +/// - Entry content (if no edit state exists) /// /// # Props /// - `initial_content`: Optional initial markdown content (for new entries) @@ -47,72 +56,120 @@ enum LoadResult { pub fn MarkdownEditor(initial_content: Option, entry_uri: Option) -> Element { let fetcher = use_context::(); - // Determine draft key - use entry URI if editing existing, otherwise "current" - let draft_key = entry_uri.clone().unwrap_or_else(|| "current".to_string()); + // Determine draft key - use entry URI if editing existing, otherwise generate TID + let draft_key = use_hook(|| { + entry_uri.clone().unwrap_or_else(|| { + format!("new:{}", jacquard::types::tid::Ticker::new().next(None).as_str()) + }) + }); - // Check if we have a local draft first - let has_local_draft = use_hook(|| storage::load_from_storage(&draft_key).is_some()); + // Parse entry URI once + let parsed_uri = entry_uri.as_ref().and_then(|s| { + jacquard::types::string::AtUri::new(s).ok().map(|u| u.into_static()) + }); - // If we have an entry_uri but no local draft, we need to fetch from PDS - let needs_fetch = entry_uri.is_some() && !has_local_draft; + // Clone draft_key for render (resource closure moves it) + let draft_key_for_render = draft_key.clone(); - // Resource returns the load result - let entry_resource = use_resource(move || { + // Resource loads and merges document state + let load_resource = use_resource(move || { let fetcher = fetcher.clone(); - let uri_str = entry_uri.clone(); + let draft_key = draft_key.clone(); + let entry_uri = parsed_uri.clone(); + let initial_content = initial_content.clone(); + async move { - if !needs_fetch { - return LoadResult::NotNeeded; - } - if let Some(uri_str) = uri_str { - if let Ok(uri) = jacquard::types::string::AtUri::new(&uri_str) { - match load_entry_for_editing(&fetcher, &uri).await { - Ok(loaded) => return LoadResult::Loaded(loaded), - Err(e) => { - tracing::error!("Failed to load entry: {}", e); - return LoadResult::Failed; + // Try to load merged state from PDS + localStorage + match load_and_merge_document(&fetcher, &draft_key, entry_uri.as_ref()).await { + Ok(Some(state)) => { + tracing::debug!("Loaded merged document state"); + return LoadResult::Loaded(state); + } + Ok(None) => { + // No existing state - check if we need to load entry content + if let Some(ref uri) = entry_uri { + // Try to load the entry content from PDS + match load_entry_for_editing(&fetcher, uri).await { + Ok(loaded) => { + // Create LoadedDocState from entry + let doc = loro::LoroDoc::new(); + let content = doc.get_text("content"); + let title = doc.get_text("title"); + let path = doc.get_text("path"); + let tags = doc.get_list("tags"); + + content.insert(0, loaded.entry.content.as_ref()).ok(); + title.insert(0, loaded.entry.title.as_ref()).ok(); + path.insert(0, loaded.entry.path.as_ref()).ok(); + if let Some(ref entry_tags) = loaded.entry.tags { + for tag in entry_tags { + let tag_str: &str = tag.as_ref(); + tags.push(tag_str).ok(); + } + } + doc.commit(); + + return LoadResult::Loaded(LoadedDocState { + doc, + entry_ref: Some(loaded.entry_ref), + edit_root: None, + last_diff: None, + is_synced: false, + }); + } + Err(e) => { + tracing::error!("Failed to load entry: {}", e); + return LoadResult::Failed(e.to_string()); + } } } + + // New document with initial content + let doc = loro::LoroDoc::new(); + if let Some(ref content) = initial_content { + let text = doc.get_text("content"); + text.insert(0, content).ok(); + doc.commit(); + } + + LoadResult::Loaded(LoadedDocState { + doc, + entry_ref: None, + edit_root: None, + last_diff: None, + is_synced: false, + }) + } + Err(e) => { + tracing::error!("Failed to load document state: {}", e); + LoadResult::Failed(e.to_string()) } } - LoadResult::Failed } }); - // Render based on resource state - match &*entry_resource.read() { - Some(LoadResult::Loaded(loaded)) => { + // Render based on load state + match &*load_resource.read() { + Some(LoadResult::Loaded(state)) => { rsx! { MarkdownEditorInner { - key: "{draft_key}", - draft_key: draft_key.clone(), - loaded_entry: Some(loaded.clone()), - initial_content: None, + key: "{draft_key_for_render}", + draft_key: draft_key_for_render.clone(), + loaded_state: state.clone(), } } } - Some(LoadResult::Failed) => { + Some(LoadResult::Failed(err)) => { rsx! { div { class: "editor-error", - "Failed to load entry. It may not exist or you may not have access." + "Failed to load: {err}" } } } - Some(LoadResult::NotNeeded) => { - rsx! { - MarkdownEditorInner { - key: "{draft_key}", - draft_key: draft_key.clone(), - loaded_entry: None, - initial_content: initial_content.clone(), - } - } - } - None => { - // Still loading + Some(LoadResult::Loading) | None => { rsx! { div { class: "editor-loading", - "Loading entry..." + "Loading..." } } } @@ -126,28 +183,23 @@ pub fn MarkdownEditor(initial_content: Option, entry_uri: Option /// - Event interception for full control over editing operations /// - Toolbar formatting buttons /// - LocalStorage auto-save with debouncing +/// - PDS sync with auto-save /// - Keyboard shortcuts (Ctrl+B for bold, Ctrl+I for italic) #[component] fn MarkdownEditorInner( draft_key: String, - loaded_entry: Option, - initial_content: Option, + loaded_state: LoadedDocState, ) -> Element { // Context for authenticated API calls let fetcher = use_context::(); let auth_state = use_context::>(); + // Create EditorDocument from loaded state (must be in use_hook for Signals) let mut document = use_hook(|| { - // Priority: loaded_entry > local storage > new with initial_content - if let Some(ref loaded) = loaded_entry { - let doc = EditorDocument::from_entry(&loaded.entry, loaded.entry_ref.clone()); - // Save to local storage so future visits use the draft - storage::save_to_storage(&doc, &draft_key).ok(); - doc - } else { - storage::load_from_storage(&draft_key) - .unwrap_or_else(|| EditorDocument::new(initial_content.clone().unwrap_or_default())) - } + let doc = EditorDocument::from_loaded_state(loaded_state.clone()); + // Save to localStorage so we have a local backup + storage::save_to_storage(&doc, &draft_key).ok(); + doc }); let editor_id = "markdown-editor"; @@ -483,9 +535,16 @@ fn MarkdownEditorInner( } } - PublishButton { - document: document.clone(), - draft_key: draft_key.to_string(), + div { class: "meta-actions", + SyncStatus { + document: document.clone(), + draft_key: draft_key.to_string(), + } + + PublishButton { + document: document.clone(), + draft_key: draft_key.to_string(), + } } } diff --git a/crates/weaver-app/src/components/editor/document.rs b/crates/weaver-app/src/components/editor/document.rs index c3840f7..a99ddb6 100644 --- a/crates/weaver-app/src/components/editor/document.rs +++ b/crates/weaver-app/src/components/editor/document.rs @@ -194,6 +194,37 @@ pub struct EditInfo { /// Covers: `######` (6), ```` ``` ```` (3), `> ` (2), `- ` (2), `999. ` (5) const BLOCK_SYNTAX_ZONE: usize = 6; +/// Pre-loaded document state that can be created outside of reactive context. +/// +/// This struct holds the raw LoroDoc (which is safe outside reactive context) +/// along with sync state metadata. Use `EditorDocument::from_loaded_state()` +/// inside a `use_hook` to convert this into a reactive EditorDocument. +/// +/// Note: Clone is a shallow/reference clone for LoroDoc (Arc-backed). +/// PartialEq always returns false since we can't meaningfully compare docs. +#[derive(Clone)] +pub struct LoadedDocState { + /// The Loro document with all content already loaded/merged. + pub doc: LoroDoc, + /// StrongRef to the entry if editing an existing record. + pub entry_ref: Option>, + /// StrongRef to the sh.weaver.edit.root record (for PDS sync). + pub edit_root: Option>, + /// StrongRef to the most recent sh.weaver.edit.diff record. + pub last_diff: Option>, + /// Whether the current doc state is synced with PDS. + /// False if local has changes not yet pushed to PDS. + pub is_synced: bool, +} + +impl PartialEq for LoadedDocState { + fn eq(&self, _other: &Self) -> bool { + // LoadedDocState contains LoroDoc which can't be meaningfully compared. + // Return false to ensure components re-render when passed as props. + false + } +} + impl EditorDocument { /// Check if a character position is within the block-syntax zone of its line. fn is_in_block_syntax_zone(&self, pos: usize) -> bool { @@ -787,8 +818,8 @@ impl EditorDocument { self.last_diff.set(diff); } - /// Check if there are unsynchronized changes since the last sync. - pub fn has_unsync_changes(&self) -> bool { + /// Check if there are unsynced changes since the last PDS sync. + pub fn has_unsynced_changes(&self) -> bool { match &self.last_synced_version { Some(synced_vv) => self.doc.oplog_vv() != *synced_vv, None => true, // Never synced, so there are changes @@ -929,6 +960,68 @@ impl EditorDocument { pending_snap: Signal::new(None), } } + + /// Create an EditorDocument from pre-loaded state. + /// + /// Use this when loading from PDS/localStorage merge outside reactive context. + /// The `LoadedDocState` contains a pre-merged LoroDoc; this method wraps it + /// with the reactive Signals needed for the editor UI. + /// + /// # Note + /// This creates Dioxus Signals. Call from within a component using `use_hook`. + pub fn from_loaded_state(state: LoadedDocState) -> Self { + let doc = state.doc; + + // Get all containers from the loaded doc + let content = doc.get_text("content"); + let title = doc.get_text("title"); + let path = doc.get_text("path"); + let created_at = doc.get_text("created_at"); + let tags = doc.get_list("tags"); + let embeds = doc.get_map("embeds"); + + // Set up undo manager + let mut undo_mgr = UndoManager::new(&doc); + undo_mgr.set_merge_interval(300); + undo_mgr.set_max_undo_steps(100); + + // Position cursor at end of content + let cursor_offset = content.len_unicode(); + let cursor_state = CursorState { + offset: cursor_offset, + affinity: Affinity::Before, + }; + let loro_cursor = content.get_cursor(cursor_offset, Side::default()); + + // Track sync state - if synced, record current version + let last_synced_version = if state.is_synced { + Some(doc.oplog_vv()) + } else { + None + }; + + Self { + doc, + content, + title, + path, + created_at, + tags, + embeds, + entry_ref: Signal::new(state.entry_ref), + edit_root: Signal::new(state.edit_root), + last_diff: Signal::new(state.last_diff), + last_synced_version, + undo_mgr: Rc::new(RefCell::new(undo_mgr)), + loro_cursor, + cursor: Signal::new(cursor_state), + selection: Signal::new(None), + composition: Signal::new(None), + composition_ended_at: Signal::new(None), + last_edit: Signal::new(None), + pending_snap: Signal::new(None), + } + } } impl PartialEq for EditorDocument { diff --git a/crates/weaver-app/src/components/editor/mod.rs b/crates/weaver-app/src/components/editor/mod.rs index e5dad24..00c94b5 100644 --- a/crates/weaver-app/src/components/editor/mod.rs +++ b/crates/weaver-app/src/components/editor/mod.rs @@ -32,7 +32,7 @@ pub use component::MarkdownEditor; // Document types #[allow(unused_imports)] -pub use document::{Affinity, CompositionState, CursorState, EditorDocument, Selection}; +pub use document::{Affinity, CompositionState, CursorState, EditorDocument, LoadedDocState, Selection}; // Formatting #[allow(unused_imports)] @@ -52,7 +52,14 @@ pub use writer::{EditorImageResolver, ImageResolver, SyntaxSpanInfo, SyntaxType, #[allow(unused_imports)] pub use storage::{ DRAFT_KEY_PREFIX, EditorSnapshot, clear_all_drafts, delete_draft, list_drafts, - load_from_storage, save_to_storage, + load_from_storage, load_snapshot_from_storage, save_to_storage, +}; + +// Sync +#[allow(unused_imports)] +pub use sync::{ + load_and_merge_document, load_edit_state_from_pds, sync_to_pds, + PdsEditState, SyncState, SyncStatus, }; // UI components diff --git a/crates/weaver-app/src/components/editor/storage.rs b/crates/weaver-app/src/components/editor/storage.rs index 39ca8a4..330aff0 100644 --- a/crates/weaver-app/src/components/editor/storage.rs +++ b/crates/weaver-app/src/components/editor/storage.rs @@ -146,6 +146,54 @@ pub fn load_from_storage(key: &str) -> Option { Some(doc) } +/// Data loaded from localStorage snapshot. +pub struct LocalSnapshotData { + /// The raw CRDT snapshot bytes + pub snapshot: Vec, + /// Entry StrongRef if editing an existing entry + pub entry_ref: Option>, +} + +/// Load snapshot data from LocalStorage (WASM only). +/// +/// Unlike `load_from_storage`, this doesn't create an EditorDocument and is safe +/// to call outside of reactive context. Use with `load_and_merge_document`. +/// +/// # Arguments +/// * `key` - Storage key (e.g., "new:abc123" for new entries, or AT-URI for existing) +#[cfg(all(target_family = "wasm", target_os = "unknown"))] +pub fn load_snapshot_from_storage(key: &str) -> Option { + let snapshot: EditorSnapshot = LocalStorage::get(storage_key(key)).ok()?; + + // Try to get CRDT snapshot bytes + let snapshot_bytes = snapshot + .snapshot + .as_ref() + .and_then(|b64| BASE64.decode(b64).ok())?; + + // Try to reconstruct entry_ref from stored URI + CID + let entry_ref = snapshot + .editing_uri + .as_ref() + .zip(snapshot.editing_cid.as_ref()) + .and_then(|(uri_str, cid_str)| { + let uri = AtUri::new(uri_str).ok()?.into_static(); + let cid = Cid::new(cid_str.as_bytes()).ok()?.into_static(); + Some(StrongRef::new().uri(uri).cid(cid).build()) + }); + + Some(LocalSnapshotData { + snapshot: snapshot_bytes, + entry_ref, + }) +} + +/// Load snapshot data from LocalStorage (non-WASM stub). +#[cfg(not(all(target_family = "wasm", target_os = "unknown")))] +pub fn load_snapshot_from_storage(_key: &str) -> Option { + None +} + /// Delete a draft from LocalStorage (WASM only). /// /// # Arguments diff --git a/crates/weaver-app/src/components/editor/sync.rs b/crates/weaver-app/src/components/editor/sync.rs index 15903c7..c3ed3fe 100644 --- a/crates/weaver-app/src/components/editor/sync.rs +++ b/crates/weaver-app/src/components/editor/sync.rs @@ -18,6 +18,7 @@ use std::collections::BTreeMap; +use jacquard::bytes::Bytes; use jacquard::cowstr::ToCowStr; use jacquard::prelude::*; use jacquard::types::blob::MimeType; @@ -40,7 +41,8 @@ use weaver_common::{WeaverError, WeaverExt}; use crate::fetch::Fetcher; -use super::document::EditorDocument; +use super::document::{EditorDocument, LoadedDocState}; +use loro::LoroDoc; const ROOT_NSID: &str = "sh.weaver.edit.root"; const DIFF_NSID: &str = "sh.weaver.edit.diff"; @@ -274,12 +276,22 @@ pub async fn create_diff( .await .ok_or_else(|| WeaverError::InvalidNotebook("Not authenticated".into()))?; - // Upload updates blob - let mime_type = MimeType::new_static("application/octet-stream"); - let blob_ref = client - .upload_blob(updates, mime_type) - .await - .map_err(|e| WeaverError::InvalidNotebook(format!("Failed to upload diff: {}", e)))?; + // Threshold for inline vs blob storage (8KB max for inline per lexicon) + const INLINE_THRESHOLD: usize = 8192; + + // Use inline for small diffs, blob for larger ones + let (blob_ref, inline_diff): (Option>, _) = + if updates.len() <= INLINE_THRESHOLD { + tracing::debug!("Using inline diff ({} bytes)", updates.len()); + (None, Some(jacquard::bytes::Bytes::from(updates))) + } else { + tracing::debug!("Using blob diff ({} bytes)", updates.len()); + let mime_type = MimeType::new_static("application/octet-stream"); + let blob = client.upload_blob(updates, mime_type).await.map_err(|e| { + WeaverError::InvalidNotebook(format!("Failed to upload diff: {}", e)) + })?; + (Some(blob.into()), None) + }; // Build DocRef - use EntryRef if published, DraftRef if not let doc_ref = build_doc_ref(draft_key, entry_uri, entry_cid); @@ -302,7 +314,8 @@ pub async fn create_diff( let diff = Diff::new() .doc(doc_ref) .root(root_ref) - .snapshot(blob_ref) + .maybe_snapshot(blob_ref) + .maybe_inline_diff(inline_diff) .maybe_prev(prev_ref) .build(); @@ -355,7 +368,7 @@ pub async fn sync_to_pds( draft_key: &str, ) -> Result { // Check if we have changes to sync - if !doc.has_unsync_changes() { + if !doc.has_unsynced_changes() { return Ok(SyncResult::NoChanges); } @@ -434,17 +447,13 @@ pub struct PdsEditState { /// The latest diff reference (if any diffs exist) pub last_diff_ref: Option>, /// The Loro snapshot bytes from the root - pub root_snapshot: Vec, + pub root_snapshot: Bytes, /// All diff update bytes in order (oldest first, by TID) - pub diff_updates: Vec>, + pub diff_updates: Vec, } /// Fetch a blob from the PDS. -async fn fetch_blob( - fetcher: &Fetcher, - did: &Did<'_>, - cid: &Cid<'_>, -) -> Result, WeaverError> { +async fn fetch_blob(fetcher: &Fetcher, did: &Did<'_>, cid: &Cid<'_>) -> Result { let pds_url = fetcher .client .pds_for_did(did) @@ -464,7 +473,7 @@ async fn fetch_blob( WeaverError::InvalidNotebook(format!("Failed to parse blob response: {}", e)) })?; - Ok(output.body.to_vec()) + Ok(output.body) } /// Load edit state from the PDS for an entry. @@ -581,13 +590,23 @@ pub async fn load_edit_state_from_pds( ); } - // Fetch all diff blobs in TID order (BTreeMap iterates in sorted order) + // Fetch all diff data in TID order (BTreeMap iterates in sorted order) + // Diffs can be stored either inline or as blobs let mut diff_updates = Vec::new(); let mut last_diff_ref = None; for (_rkey, (diff, cid, uri)) in &diffs_by_rkey { - let blob_bytes = fetch_blob(fetcher, &root_id.did(), diff.snapshot.blob().cid()).await?; - diff_updates.push(blob_bytes); + // Check for inline diff first, then fall back to blob + let diff_bytes = if let Some(ref inline) = diff.inline_diff { + inline.clone() + } else if let Some(ref snapshot) = diff.snapshot { + fetch_blob(fetcher, &root_id.did(), snapshot.blob().cid()).await? + } else { + tracing::warn!("Diff has neither inline_diff nor snapshot, skipping"); + continue; + }; + + diff_updates.push(diff_bytes); // Track the last diff (will be the one with highest TID after iteration) last_diff_ref = Some(StrongRef::new().uri(uri.clone()).cid(cid.clone()).build()); @@ -601,7 +620,7 @@ pub async fn load_edit_state_from_pds( })) } -/// Load an EditorDocument by merging local storage and PDS state. +/// Load document state by merging local storage and PDS state. /// /// This is the main entry point for loading a document with full sync support. /// It: @@ -609,7 +628,8 @@ pub async fn load_edit_state_from_pds( /// 2. Loads from PDS (if available) /// 3. Merges both using Loro's CRDT merge /// -/// The result is a document with all changes from both sources. +/// The result is a `LoadedDocState` containing a pre-merged LoroDoc that can be +/// converted to an EditorDocument inside a reactive context using `use_hook`. /// /// # Arguments /// * `fetcher` - The authenticated fetcher @@ -617,16 +637,16 @@ pub async fn load_edit_state_from_pds( /// * `entry_uri` - Optional AT-URI if editing an existing entry /// /// # Returns -/// A merged EditorDocument, or None if no state exists anywhere. +/// A `LoadedDocState` with merged state, or None if no state exists anywhere. pub async fn load_and_merge_document( fetcher: &Fetcher, draft_key: &str, entry_uri: Option<&AtUri<'_>>, -) -> Result, WeaverError> { - use super::storage::load_from_storage; +) -> Result, WeaverError> { + use super::storage::load_snapshot_from_storage; - // Load from localStorage - let local_doc = load_from_storage(draft_key); + // Load snapshot + entry_ref from localStorage + let local_data = load_snapshot_from_storage(draft_key); // Load from PDS (only if we have an entry URI) let pds_state = if let Some(uri) = entry_uri { @@ -635,57 +655,294 @@ pub async fn load_and_merge_document( None }; - match (local_doc, pds_state) { + match (local_data, pds_state) { (None, None) => Ok(None), - (Some(doc), None) => { - // Only local state exists + (Some(local), None) => { + // Only local state exists - build LoroDoc from snapshot tracing::debug!("Loaded document from localStorage only"); - Ok(Some(doc)) + let doc = LoroDoc::new(); + if let Err(e) = doc.import(&local.snapshot) { + tracing::warn!("Failed to import local snapshot: {:?}", e); + } + + Ok(Some(LoadedDocState { + doc, + entry_ref: local.entry_ref, // Restored from localStorage + edit_root: None, + last_diff: None, + is_synced: false, // Local-only, not synced to PDS + })) } (None, Some(pds)) => { // Only PDS state exists - reconstruct from snapshot + diffs tracing::debug!("Loaded document from PDS only"); - let mut doc = EditorDocument::from_snapshot(&pds.root_snapshot, None, 0); + let doc = LoroDoc::new(); + + // Import root snapshot + if let Err(e) = doc.import(&pds.root_snapshot) { + tracing::warn!("Failed to import PDS root snapshot: {:?}", e); + } // Apply all diffs in order for updates in &pds.diff_updates { - if let Err(e) = doc.import_updates(updates) { + if let Err(e) = doc.import(updates) { tracing::warn!("Failed to apply diff update: {:?}", e); } } - // Set sync state so we don't re-upload what we just downloaded - doc.set_synced_from_pds(pds.root_ref, pds.last_diff_ref); - - Ok(Some(doc)) + Ok(Some(LoadedDocState { + doc, + entry_ref: None, // Entry ref comes from the entry itself, not edit state + edit_root: Some(pds.root_ref), + last_diff: pds.last_diff_ref, + is_synced: true, // Just loaded from PDS, fully synced + })) } - (Some(mut local_doc), Some(pds)) => { + (Some(local), Some(pds)) => { // Both exist - merge using CRDT tracing::debug!("Merging document from localStorage and PDS"); - // Import PDS root snapshot into local doc - // Loro will automatically merge concurrent changes - if let Err(e) = local_doc.import_updates(&pds.root_snapshot) { + let doc = LoroDoc::new(); + + // Import local snapshot first + if let Err(e) = doc.import(&local.snapshot) { + tracing::warn!("Failed to import local snapshot: {:?}", e); + } + + // Import PDS root snapshot - Loro will merge + if let Err(e) = doc.import(&pds.root_snapshot) { tracing::warn!("Failed to merge PDS root snapshot: {:?}", e); } // Import all diffs for updates in &pds.diff_updates { - if let Err(e) = local_doc.import_updates(updates) { + if let Err(e) = doc.import(updates) { tracing::warn!("Failed to merge PDS diff: {:?}", e); } } - // Update sync state - // We keep the PDS root/diff refs since that's where we'll push updates - local_doc.set_edit_root(Some(pds.root_ref)); - local_doc.set_last_diff(pds.last_diff_ref); - // Don't call set_synced_from_pds - local changes still need syncing + Ok(Some(LoadedDocState { + doc, + entry_ref: local.entry_ref, // Restored from localStorage + edit_root: Some(pds.root_ref), + last_diff: pds.last_diff_ref, + is_synced: false, // Local had state, may have unsynced changes + })) + } + } +} + +// ============================================================================ +// Sync UI Components +// ============================================================================ + +use crate::auth::AuthState; +use dioxus::prelude::*; + +/// Sync status states for UI display. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum SyncState { + /// All local changes have been synced to PDS + Synced, + /// Currently syncing to PDS + Syncing, + /// Has local changes not yet synced + Unsynced, + /// Last sync failed + Error, + /// Not authenticated or sync disabled + Disabled, +} + +/// Props for the SyncStatus component. +#[derive(Props, Clone, PartialEq)] +pub struct SyncStatusProps { + /// The editor document to sync + pub document: EditorDocument, + /// Draft key for this document + pub draft_key: String, + /// Auto-sync interval in milliseconds (0 to disable) + #[props(default = 30_000)] + pub auto_sync_interval_ms: u32, +} + +/// Sync status indicator with auto-sync functionality. +/// +/// Displays the current sync state and automatically syncs to PDS periodically. +#[component] +pub fn SyncStatus(props: SyncStatusProps) -> Element { + let fetcher = use_context::(); + let auth_state = use_context::>(); + + // Sync state management + let mut sync_state = use_signal(|| { + if props.document.has_unsynced_changes() { + SyncState::Unsynced + } else { + SyncState::Synced + } + }); + let mut last_error: Signal> = use_signal(|| None); + + let doc = props.document.clone(); + let draft_key = props.draft_key.clone(); + + // Check if we're authenticated and have an entry to sync + let is_authenticated = auth_state.read().is_authenticated(); + let has_entry = doc.entry_ref().is_some(); + + // Auto-sync trigger signal - set to true to trigger a sync + let mut trigger_sync = use_signal(|| false); + + // Auto-sync timer (WASM only) - just sets the trigger, doesn't access signals directly + #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] + { + let auto_sync_interval = props.auto_sync_interval_ms; + let doc_for_check = doc.clone(); + + use_effect(move || { + if auto_sync_interval == 0 { + return; + } + + let doc = doc_for_check.clone(); + + let interval = gloo_timers::callback::Interval::new(auto_sync_interval, move || { + // Only trigger if there are unsynced changes and we're not already syncing + if doc.has_unsynced_changes() { + // This just sets a signal - the actual sync happens in use_future below + trigger_sync.set(true); + } + }); + + interval.forget(); + }); + } + + #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))] + let mut trigger_sync = use_signal(|| false); + + // Update sync state when document changes + // Note: We use peek() to avoid creating a reactive dependency on sync_state + let doc_for_effect = doc.clone(); + use_effect(move || { + // Check for unsynced changes (reads last_edit signal for reactivity) + let _edit = doc_for_effect.last_edit(); + + // Use peek to avoid reactive loop + let current_state = *sync_state.peek(); + if current_state != SyncState::Syncing { + if doc_for_effect.has_unsynced_changes() && current_state != SyncState::Unsynced { + sync_state.set(SyncState::Unsynced); + } + } + }); + + // Sync effect - watches trigger_sync and performs sync when triggered + let doc_for_sync = doc.clone(); + let draft_key_for_sync = draft_key.clone(); + let fetcher_for_sync = fetcher.clone(); + + let doc_for_check = doc.clone(); + use_effect(move || { + // Read trigger to create reactive dependency + let should_sync = *trigger_sync.read(); + + if !should_sync { + return; + } + + // Reset trigger immediately + trigger_sync.set(false); + + // Check if already syncing + if *sync_state.peek() == SyncState::Syncing { + return; + } + + // Check if authenticated and has entry + if !is_authenticated || !has_entry { + return; + } + + // Check if there are actually changes to sync + if !doc_for_check.has_unsynced_changes() { + // Already synced, just update state + sync_state.set(SyncState::Synced); + return; + } + + sync_state.set(SyncState::Syncing); + + let mut doc = doc_for_sync.clone(); + let draft_key = draft_key_for_sync.clone(); + let fetcher = fetcher_for_sync.clone(); + + // Spawn the async work + spawn(async move { + match sync_to_pds(&fetcher, &mut doc, &draft_key).await { + Ok(SyncResult::NoChanges) => { + // No changes to sync - already up to date + sync_state.set(SyncState::Synced); + last_error.set(None); + tracing::debug!("No changes to sync"); + } + Ok(_) => { + sync_state.set(SyncState::Synced); + last_error.set(None); + tracing::debug!("Sync completed successfully"); + } + Err(e) => { + sync_state.set(SyncState::Error); + last_error.set(Some(e.to_string())); + tracing::warn!("Sync failed: {}", e); + } + } + }); + }); + + // Manual sync handler - just sets the trigger if there are changes + let doc_for_manual = doc.clone(); + let on_manual_sync = move |_| { + if *sync_state.peek() == SyncState::Syncing { + return; // Already syncing + } + if !doc_for_manual.has_unsynced_changes() { + // Already synced + sync_state.set(SyncState::Synced); + return; + } + trigger_sync.set(true); + }; + + // Determine display state + let display_state = if !is_authenticated { + SyncState::Disabled + } else if !has_entry { + SyncState::Disabled // Can't sync unpublished entries + } else { + *sync_state.read() + }; + + let (icon, label, class) = match display_state { + SyncState::Synced => ("✓", "Synced", "sync-status synced"), + SyncState::Syncing => ("◌", "Syncing...", "sync-status syncing"), + SyncState::Unsynced => ("●", "Unsynced", "sync-status unsynced"), + SyncState::Error => ("✕", "Sync error", "sync-status error"), + SyncState::Disabled => ("○", "Sync disabled", "sync-status disabled"), + }; + + rsx! { + div { + class: "{class}", + title: if let Some(ref err) = *last_error.read() { err.clone() } else { label.to_string() }, + onclick: on_manual_sync, - Ok(Some(local_doc)) + span { class: "sync-icon", "{icon}" } + span { class: "sync-label", "{label}" } } } } diff --git a/lexicons/edit/diff.json b/lexicons/edit/diff.json index 4d53b91..c824c49 100644 --- a/lexicons/edit/diff.json +++ b/lexicons/edit/diff.json @@ -8,13 +8,19 @@ "key": "tid", "record": { "type": "object", - "required": ["snapshot", "root", "doc"], + "required": ["root", "doc"], "properties": { "snapshot": { "type": "blob", + "description": "Diff from previous diff. Either this or inlineDiff must be present to be valid", "accept": ["*/*"], "maxSize": 3000000 }, + "inlineDiff": { + "type": "bytes", + "description": "An inline diff for for small edit batches. Either this or snapshot must be present to be valid", + "maxLength": 8192 + }, "root": { "type": "ref", "ref": "com.atproto.repo.strongRef" -- knotmirror 0.0.1