diff --git a/Cargo.lock b/Cargo.lock index 06d01a0..c062f9e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -12127,6 +12127,7 @@ dependencies = [ "weaver-common", "weaver-editor-browser", "weaver-editor-core", + "weaver-editor-crdt", "weaver-embed-worker", "weaver-renderer", "web-sys", @@ -12237,6 +12238,32 @@ dependencies = [ "web-time", ] +[[package]] +name = "weaver-editor-crdt" +version = "0.1.0" +dependencies = [ + "base64 0.22.1", + "console_error_panic_hook", + "futures-util", + "gloo-worker", + "jacquard", + "loro", + "n0-future 0.1.3", + "serde", + "smol_str", + "thiserror 2.0.17", + "tokio", + "tracing", + "tracing-subscriber", + "tracing-wasm", + "wasm-bindgen", + "wasm-bindgen-futures", + "weaver-api", + "weaver-common", + "weaver-editor-core", + "weaver-renderer", +] + [[package]] name = "weaver-embed-worker" version = "0.1.0" diff --git a/crates/weaver-app/Cargo.toml b/crates/weaver-app/Cargo.toml index 52a17e7..a1d7e5c 100644 --- a/crates/weaver-app/Cargo.toml +++ b/crates/weaver-app/Cargo.toml @@ -50,6 +50,7 @@ dioxus = { version = "0.7.1", features = ["router"] } weaver-common = { path = "../weaver-common", features = ["cache", "perf"] } weaver-editor-core = { path = "../weaver-editor-core" } weaver-editor-browser = { path = "../weaver-editor-browser" } +weaver-editor-crdt = { path = "../weaver-editor-crdt" } jacquard = { workspace = true}#, features = ["streaming"] } jacquard-lexicon = { workspace = true } jacquard-identity = { workspace = true } diff --git a/crates/weaver-app/public/editor_worker.js b/crates/weaver-app/public/editor_worker.js index 2fe4792..86b645c 100644 --- a/crates/weaver-app/public/editor_worker.js +++ b/crates/weaver-app/public/editor_worker.js @@ -17,71 +17,6 @@ let wasm_bindgen; ? { register: () => {}, unregister: () => {} } : new FinalizationRegistry(state => state.dtor(state.a, state.b)); - function debugString(val) { - // primitive types - const type = typeof val; - if (type == 'number' || type == 'boolean' || val == null) { - return `${val}`; - } - if (type == 'string') { - return `"${val}"`; - } - if (type == 'symbol') { - const description = val.description; - if (description == null) { - return 'Symbol'; - } else { - return `Symbol(${description})`; - } - } - if (type == 'function') { - const name = val.name; - if (typeof name == 'string' && name.length > 0) { - return `Function(${name})`; - } else { - return 'Function'; - } - } - // objects - if (Array.isArray(val)) { - const length = val.length; - let debug = '['; - if (length > 0) { - debug += debugString(val[0]); - } - for(let i = 1; i < length; i++) { - debug += ', ' + debugString(val[i]); - } - debug += ']'; - return debug; - } - // Test for built-in - const builtInMatches = /\[object ([^\]]+)\]/.exec(toString.call(val)); - let className; - if (builtInMatches && builtInMatches.length > 1) { - className = builtInMatches[1]; - } else { - // Failed to match the standard '[object ClassName]' - return toString.call(val); - } - if (className == 'Object') { - // we're a user defined class or Object - // JSON.stringify avoids problems with cycles, and is generally much - // easier than looping through ownProperties of `val`. - try { - return 'Object(' + JSON.stringify(val) + ')'; - } catch (_) { - return 'Object'; - } - } - // errors - if (val instanceof Error) { - return `${val.name}: ${val.message}\n${val.stack}`; - } - // TODO we could test for more things here, like `Set`s and `Map`s. - return className; - } - function getArrayU8FromWasm0(ptr, len) { ptr = ptr >>> 0; return getUint8ArrayMemory0().subarray(ptr / 1, ptr / 1 + len); @@ -232,48 +167,20 @@ let wasm_bindgen; let WASM_VECTOR_LEN = 0; - function wasm_bindgen_ff1081b5ed996cf4___convert__closures_____invoke___web_sys_2f551ca386a5254a___features__gen_MessageEvent__MessageEvent_____(arg0, arg1, arg2) { - wasm.wasm_bindgen_ff1081b5ed996cf4___convert__closures_____invoke___web_sys_2f551ca386a5254a___features__gen_MessageEvent__MessageEvent_____(arg0, arg1, arg2); - } - - function wasm_bindgen_ff1081b5ed996cf4___convert__closures_____invoke___web_sys_2f551ca386a5254a___features__gen_MessageEvent__MessageEvent______1_(arg0, arg1, arg2) { - wasm.wasm_bindgen_ff1081b5ed996cf4___convert__closures_____invoke___web_sys_2f551ca386a5254a___features__gen_MessageEvent__MessageEvent______1_(arg0, arg1, arg2); - } - - function wasm_bindgen_ff1081b5ed996cf4___convert__closures_____invoke______(arg0, arg1) { - wasm.wasm_bindgen_ff1081b5ed996cf4___convert__closures_____invoke______(arg0, arg1); + function wasm_bindgen_ff1081b5ed996cf4___convert__closures_____invoke___web_sys_8097425c49dd63ff___features__gen_MessageEvent__MessageEvent_____(arg0, arg1, arg2) { + wasm.wasm_bindgen_ff1081b5ed996cf4___convert__closures_____invoke___web_sys_8097425c49dd63ff___features__gen_MessageEvent__MessageEvent_____(arg0, arg1, arg2); } function wasm_bindgen_ff1081b5ed996cf4___convert__closures_____invoke___wasm_bindgen_ff1081b5ed996cf4___JsValue_____(arg0, arg1, arg2) { wasm.wasm_bindgen_ff1081b5ed996cf4___convert__closures_____invoke___wasm_bindgen_ff1081b5ed996cf4___JsValue_____(arg0, arg1, arg2); } - function wasm_bindgen_ff1081b5ed996cf4___convert__closures_____invoke_______1_(arg0, arg1) { - wasm.wasm_bindgen_ff1081b5ed996cf4___convert__closures_____invoke_______1_(arg0, arg1); - } - - function wasm_bindgen_ff1081b5ed996cf4___convert__closures_____invoke___web_sys_2f551ca386a5254a___features__gen_CloseEvent__CloseEvent_____(arg0, arg1, arg2) { - wasm.wasm_bindgen_ff1081b5ed996cf4___convert__closures_____invoke___web_sys_2f551ca386a5254a___features__gen_CloseEvent__CloseEvent_____(arg0, arg1, arg2); - } - - function wasm_bindgen_ff1081b5ed996cf4___convert__closures_____invoke_______2_(arg0, arg1) { - wasm.wasm_bindgen_ff1081b5ed996cf4___convert__closures_____invoke_______2_(arg0, arg1); - } - function wasm_bindgen_ff1081b5ed996cf4___convert__closures_____invoke___wasm_bindgen_ff1081b5ed996cf4___JsValue__wasm_bindgen_ff1081b5ed996cf4___JsValue_____(arg0, arg1, arg2, arg3) { wasm.wasm_bindgen_ff1081b5ed996cf4___convert__closures_____invoke___wasm_bindgen_ff1081b5ed996cf4___JsValue__wasm_bindgen_ff1081b5ed996cf4___JsValue_____(arg0, arg1, arg2, arg3); } - const __wbindgen_enum_BinaryType = ["blob", "arraybuffer"]; - const __wbindgen_enum_ReadableStreamType = ["bytes"]; - const __wbindgen_enum_RequestCache = ["default", "no-store", "reload", "no-cache", "force-cache", "only-if-cached"]; - - const __wbindgen_enum_RequestCredentials = ["omit", "same-origin", "include"]; - - const __wbindgen_enum_RequestMode = ["same-origin", "no-cors", "cors", "navigate"]; - const IntoUnderlyingByteSourceFinalization = (typeof FinalizationRegistry === 'undefined') ? { register: () => {}, unregister: () => {} } : new FinalizationRegistry(ptr => wasm.__wbg_intounderlyingbytesource_free(ptr >>> 0, 1)); @@ -454,18 +361,6 @@ let wasm_bindgen; function __wbg_get_imports() { const imports = {}; imports.wbg = {}; - imports.wbg.__wbg___wbindgen_boolean_get_dea25b33882b895b = function(arg0) { - const v = arg0; - const ret = typeof(v) === 'boolean' ? v : undefined; - return isLikeNone(ret) ? 0xFFFFFF : ret ? 1 : 0; - }; - imports.wbg.__wbg___wbindgen_debug_string_adfb662ae34724b6 = function(arg0, arg1) { - const ret = debugString(arg1); - const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); - const len1 = WASM_VECTOR_LEN; - getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true); - getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true); - }; imports.wbg.__wbg___wbindgen_is_function_8d400b8b1af978cd = function(arg0) { const ret = typeof(arg0) === 'function'; return ret; @@ -483,40 +378,12 @@ let wasm_bindgen; const ret = arg0 === undefined; return ret; }; - imports.wbg.__wbg___wbindgen_string_get_a2a31e16edf96e42 = function(arg0, arg1) { - const obj = arg1; - const ret = typeof(obj) === 'string' ? obj : undefined; - var ptr1 = isLikeNone(ret) ? 0 : passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); - var len1 = WASM_VECTOR_LEN; - getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true); - getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true); - }; imports.wbg.__wbg___wbindgen_throw_dd24417ed36fc46e = function(arg0, arg1) { throw new Error(getStringFromWasm0(arg0, arg1)); }; imports.wbg.__wbg__wbg_cb_unref_87dfb5aaa0cbcea7 = function(arg0) { arg0._wbg_cb_unref(); }; - imports.wbg.__wbg_abort_07646c894ebbf2bd = function(arg0) { - arg0.abort(); - }; - imports.wbg.__wbg_abort_399ecbcfd6ef3c8e = function(arg0, arg1) { - arg0.abort(arg1); - }; - imports.wbg.__wbg_addEventListener_e792423147a80626 = function() { return handleError(function (arg0, arg1, arg2, arg3) { - arg0.addEventListener(getStringFromWasm0(arg1, arg2), arg3); - }, arguments) }; - imports.wbg.__wbg_append_c5cbdf46455cc776 = function() { return handleError(function (arg0, arg1, arg2, arg3, arg4) { - arg0.append(getStringFromWasm0(arg1, arg2), getStringFromWasm0(arg3, arg4)); - }, arguments) }; - imports.wbg.__wbg_arrayBuffer_c04af4fce566092d = function() { return handleError(function (arg0) { - const ret = arg0.arrayBuffer(); - return ret; - }, arguments) }; - imports.wbg.__wbg_body_947b901c33f7fe32 = function(arg0) { - const ret = arg0.body; - return isLikeNone(ret) ? 0 : addToExternrefTable0(ret); - }; imports.wbg.__wbg_buffer_6cb2fecb1f253d71 = function(arg0) { const ret = arg0.buffer; return ret; @@ -541,41 +408,15 @@ let wasm_bindgen; const ret = arg0.call(arg1); return ret; }, arguments) }; - imports.wbg.__wbg_cancel_a65cf45dca50ba4c = function(arg0) { - const ret = arg0.cancel(); - return ret; - }; - imports.wbg.__wbg_catch_b9db41d97d42bd02 = function(arg0, arg1) { - const ret = arg0.catch(arg1); - return ret; - }; - imports.wbg.__wbg_clearTimeout_b716ecb44bea14ed = function(arg0) { - const ret = clearTimeout(arg0); - return ret; - }; - imports.wbg.__wbg_clearTimeout_f7a6c75a3d228439 = function() { return handleError(function (arg0, arg1) { - arg0.clearTimeout(arg1); - }, arguments) }; imports.wbg.__wbg_close_0af5661bf3d335f2 = function() { return handleError(function (arg0) { arg0.close(); }, arguments) }; imports.wbg.__wbg_close_0b472ca2d13f54f7 = function(arg0) { arg0.close(); }; - imports.wbg.__wbg_close_1db3952de1b5b1cf = function() { return handleError(function (arg0) { - arg0.close(); - }, arguments) }; imports.wbg.__wbg_close_3ec111e7b23d94d8 = function() { return handleError(function (arg0) { arg0.close(); }, arguments) }; - imports.wbg.__wbg_code_85a811fe6ca962be = function(arg0) { - const ret = arg0.code; - return ret; - }; - imports.wbg.__wbg_code_c2a85f2863ec11b3 = function(arg0) { - const ret = arg0.code; - return ret; - }; imports.wbg.__wbg_crypto_574e78ad8b13b65f = function(arg0) { const ret = arg0.crypto; return ret; @@ -584,10 +425,6 @@ let wasm_bindgen; const ret = arg0.data; return ret; }; - imports.wbg.__wbg_done_62ea16af4ce34b24 = function(arg0) { - const ret = arg0.done; - return ret; - }; imports.wbg.__wbg_enqueue_a7e6b1ee87963aad = function() { return handleError(function (arg0, arg1) { arg0.enqueue(arg1); }, arguments) }; @@ -602,74 +439,9 @@ let wasm_bindgen; wasm.__wbindgen_free(deferred0_0, deferred0_1, 1); } }; - imports.wbg.__wbg_fetch_7fb7602a1bf647ec = function(arg0) { - const ret = fetch(arg0); - return ret; - }; - imports.wbg.__wbg_fetch_90447c28cc0b095e = function(arg0, arg1) { - const ret = arg0.fetch(arg1); - return ret; - }; - imports.wbg.__wbg_getRandomValues_1c61fac11405ffdc = function() { return handleError(function (arg0, arg1) { - globalThis.crypto.getRandomValues(getArrayU8FromWasm0(arg0, arg1)); - }, arguments) }; imports.wbg.__wbg_getRandomValues_b8f5dbd5f3995a9e = function() { return handleError(function (arg0, arg1) { arg0.getRandomValues(arg1); }, arguments) }; - imports.wbg.__wbg_getReader_48e00749fe3f6089 = function() { return handleError(function (arg0) { - const ret = arg0.getReader(); - return ret; - }, arguments) }; - imports.wbg.__wbg_get_af9dab7e9603ea93 = function() { return handleError(function (arg0, arg1) { - const ret = Reflect.get(arg0, arg1); - return ret; - }, arguments) }; - imports.wbg.__wbg_get_done_f98a6e62c4e18fb9 = function(arg0) { - const ret = arg0.done; - return isLikeNone(ret) ? 0xFFFFFF : ret ? 1 : 0; - }; - imports.wbg.__wbg_get_value_63e39884ef11812e = function(arg0) { - const ret = arg0.value; - return ret; - }; - imports.wbg.__wbg_has_0e670569d65d3a45 = function() { return handleError(function (arg0, arg1) { - const ret = Reflect.has(arg0, arg1); - return ret; - }, arguments) }; - imports.wbg.__wbg_headers_654c30e1bcccc552 = function(arg0) { - const ret = arg0.headers; - return ret; - }; - imports.wbg.__wbg_instanceof_ArrayBuffer_f3320d2419cd0355 = function(arg0) { - let result; - try { - result = arg0 instanceof ArrayBuffer; - } catch (_) { - result = false; - } - const ret = result; - return ret; - }; - imports.wbg.__wbg_instanceof_Blob_e9c51ce33a4b6181 = function(arg0) { - let result; - try { - result = arg0 instanceof Blob; - } catch (_) { - result = false; - } - const ret = result; - return ret; - }; - imports.wbg.__wbg_instanceof_Response_cd74d1c2ac92cb0b = function(arg0) { - let result; - try { - result = arg0 instanceof Response; - } catch (_) { - result = false; - } - const ret = result; - return ret; - }; imports.wbg.__wbg_instanceof_Window_b5cf7783caa68180 = function(arg0) { let result; try { @@ -680,10 +452,6 @@ let wasm_bindgen; const ret = result; return ret; }; - imports.wbg.__wbg_iterator_27b7c8b35ab3e86b = function() { - const ret = Symbol.iterator; - return ret; - }; imports.wbg.__wbg_length_22ac23eaec9d8053 = function(arg0) { const ret = arg0.length; return ret; @@ -729,41 +497,10 @@ let wasm_bindgen; wasm.__wbindgen_free(deferred1_0, deferred1_1, 1); } }, arguments) }; - imports.wbg.__wbg_message_a4e9a39ee8f92b17 = function(arg0, arg1) { - const ret = arg1.message; - const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); - const len1 = WASM_VECTOR_LEN; - getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true); - getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true); - }; imports.wbg.__wbg_msCrypto_a61aeb35a24c1329 = function(arg0) { const ret = arg0.msCrypto; return ret; }; - imports.wbg.__wbg_new_1ba21ce319a06297 = function() { - const ret = new Object(); - return ret; - }; - imports.wbg.__wbg_new_25f239778d6112b9 = function() { - const ret = new Array(); - return ret; - }; - imports.wbg.__wbg_new_3c79b3bb1b32b7d3 = function() { return handleError(function () { - const ret = new Headers(); - return ret; - }, arguments) }; - imports.wbg.__wbg_new_6421f6084cc5bc5a = function(arg0) { - const ret = new Uint8Array(arg0); - return ret; - }; - imports.wbg.__wbg_new_7c30d1f874652e62 = function() { return handleError(function (arg0, arg1) { - const ret = new WebSocket(getStringFromWasm0(arg0, arg1)); - return ret; - }, arguments) }; - imports.wbg.__wbg_new_881a222c65f168fc = function() { return handleError(function () { - const ret = new AbortController(); - return ret; - }, arguments) }; imports.wbg.__wbg_new_8a6f238a6ece86ea = function() { const ret = new Error(); return ret; @@ -806,34 +543,10 @@ let wasm_bindgen; const ret = new Uint8Array(arg0 >>> 0); return ret; }; - imports.wbg.__wbg_new_with_str_and_init_c5748f76f5108934 = function() { return handleError(function (arg0, arg1, arg2) { - const ret = new Request(getStringFromWasm0(arg0, arg1), arg2); - return ret; - }, arguments) }; - imports.wbg.__wbg_new_with_str_sequence_073466a4a5387941 = function() { return handleError(function (arg0, arg1, arg2) { - const ret = new WebSocket(getStringFromWasm0(arg0, arg1), arg2); - return ret; - }, arguments) }; - imports.wbg.__wbg_next_138a17bbf04e926c = function(arg0) { - const ret = arg0.next; - return ret; - }; - imports.wbg.__wbg_next_3cfe5c0fe2a4cc53 = function() { return handleError(function (arg0) { - const ret = arg0.next(); - return ret; - }, arguments) }; imports.wbg.__wbg_node_905d3e251edff8a2 = function(arg0) { const ret = arg0.node; return ret; }; - imports.wbg.__wbg_now_2c95c9de01293173 = function(arg0) { - const ret = arg0.now(); - return ret; - }; - imports.wbg.__wbg_now_69d776cd24f5215b = function() { - const ret = Date.now(); - return ret; - }; imports.wbg.__wbg_now_8cf15d6e317793e1 = function(arg0) { const ret = arg0.now(); return ret; @@ -842,10 +555,6 @@ let wasm_bindgen; const ret = Date.now(); return ret; }; - imports.wbg.__wbg_performance_7a3ffd0b17f663ad = function(arg0) { - const ret = arg0.performance; - return ret; - }; imports.wbg.__wbg_performance_c77a440eff2efd9b = function(arg0) { const ret = arg0.performance; return isLikeNone(ret) ? 0 : addToExternrefTable0(ret); @@ -860,10 +569,6 @@ let wasm_bindgen; imports.wbg.__wbg_prototypesetcall_dfe9b766cdc1f1fd = function(arg0, arg1, arg2) { Uint8Array.prototype.set.call(getArrayU8FromWasm0(arg0, arg1), arg2); }; - imports.wbg.__wbg_push_7d9be8f38fc13975 = function(arg0, arg1) { - const ret = arg0.push(arg1); - return ret; - }; imports.wbg.__wbg_queueMicrotask_9b549dfce8865860 = function(arg0) { const ret = arg0.queueMicrotask; return ret; @@ -874,27 +579,6 @@ let wasm_bindgen; imports.wbg.__wbg_randomFillSync_ac0988aba3254290 = function() { return handleError(function (arg0, arg1) { arg0.randomFillSync(arg1); }, arguments) }; - imports.wbg.__wbg_read_39c4b35efcd03c25 = function(arg0) { - const ret = arg0.read(); - return ret; - }; - imports.wbg.__wbg_readyState_9d0976dcad561aa9 = function(arg0) { - const ret = arg0.readyState; - return ret; - }; - imports.wbg.__wbg_reason_d4eb9e40592438c2 = function(arg0, arg1) { - const ret = arg1.reason; - const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); - const len1 = WASM_VECTOR_LEN; - getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true); - getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true); - }; - imports.wbg.__wbg_releaseLock_a5912f590b185180 = function(arg0) { - arg0.releaseLock(); - }; - imports.wbg.__wbg_removeEventListener_54bf92f4a849bd7d = function() { return handleError(function (arg0, arg1, arg2, arg3) { - arg0.removeEventListener(getStringFromWasm0(arg1, arg2), arg3); - }, arguments) }; imports.wbg.__wbg_require_60cc747a6bc5215a = function() { return handleError(function () { const ret = module.require; return ret; @@ -906,69 +590,12 @@ let wasm_bindgen; imports.wbg.__wbg_respond_9f7fc54636c4a3af = function() { return handleError(function (arg0, arg1) { arg0.respond(arg1 >>> 0); }, arguments) }; - imports.wbg.__wbg_send_7cc36bb628044281 = function() { return handleError(function (arg0, arg1, arg2) { - arg0.send(getStringFromWasm0(arg1, arg2)); - }, arguments) }; - imports.wbg.__wbg_send_ea59e150ab5ebe08 = function() { return handleError(function (arg0, arg1, arg2) { - arg0.send(getArrayU8FromWasm0(arg1, arg2)); - }, arguments) }; - imports.wbg.__wbg_setTimeout_4302406184dcc5be = function(arg0, arg1) { - const ret = setTimeout(arg0, arg1); - return ret; - }; - imports.wbg.__wbg_setTimeout_ceaa8eadc563d26e = function() { return handleError(function (arg0, arg1, arg2) { - const ret = arg0.setTimeout(arg1, arg2); - return ret; - }, arguments) }; imports.wbg.__wbg_set_169e13b608078b7b = function(arg0, arg1, arg2) { arg0.set(getArrayU8FromWasm0(arg1, arg2)); }; - imports.wbg.__wbg_set_binaryType_73e8c75df97825f8 = function(arg0, arg1) { - arg0.binaryType = __wbindgen_enum_BinaryType[arg1]; - }; - imports.wbg.__wbg_set_body_8e743242d6076a4f = function(arg0, arg1) { - arg0.body = arg1; - }; - imports.wbg.__wbg_set_cache_0e437c7c8e838b9b = function(arg0, arg1) { - arg0.cache = __wbindgen_enum_RequestCache[arg1]; - }; - imports.wbg.__wbg_set_credentials_55ae7c3c106fd5be = function(arg0, arg1) { - arg0.credentials = __wbindgen_enum_RequestCredentials[arg1]; - }; - imports.wbg.__wbg_set_handle_event_14baa3949ef6909d = function(arg0, arg1) { - arg0.handleEvent = arg1; - }; - imports.wbg.__wbg_set_headers_5671cf088e114d2b = function(arg0, arg1) { - arg0.headers = arg1; - }; - imports.wbg.__wbg_set_method_76c69e41b3570627 = function(arg0, arg1, arg2) { - arg0.method = getStringFromWasm0(arg1, arg2); - }; - imports.wbg.__wbg_set_mode_611016a6818fc690 = function(arg0, arg1) { - arg0.mode = __wbindgen_enum_RequestMode[arg1]; - }; - imports.wbg.__wbg_set_onclose_032729b3d7ed7a9e = function(arg0, arg1) { - arg0.onclose = arg1; - }; - imports.wbg.__wbg_set_onerror_7819daa6af176ddb = function(arg0, arg1) { - arg0.onerror = arg1; - }; imports.wbg.__wbg_set_onmessage_5fe29d0fb54cb575 = function(arg0, arg1) { arg0.onmessage = arg1; }; - imports.wbg.__wbg_set_onmessage_71321d0bed69856c = function(arg0, arg1) { - arg0.onmessage = arg1; - }; - imports.wbg.__wbg_set_onopen_6d4abedb27ba5656 = function(arg0, arg1) { - arg0.onopen = arg1; - }; - imports.wbg.__wbg_set_signal_e89be862d0091009 = function(arg0, arg1) { - arg0.signal = arg1; - }; - imports.wbg.__wbg_signal_3c14fbdc89694b39 = function(arg0) { - const ret = arg0.signal; - return ret; - }; imports.wbg.__wbg_stack_0ed75d68575b0f3c = function(arg0, arg1) { const ret = arg1.stack; const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); @@ -992,44 +619,14 @@ let wasm_bindgen; const ret = typeof window === 'undefined' ? null : window; return isLikeNone(ret) ? 0 : addToExternrefTable0(ret); }; - imports.wbg.__wbg_status_9bfc680efca4bdfd = function(arg0) { - const ret = arg0.status; - return ret; - }; - imports.wbg.__wbg_stringify_655a6390e1f5eb6b = function() { return handleError(function (arg0) { - const ret = JSON.stringify(arg0); - return ret; - }, arguments) }; imports.wbg.__wbg_subarray_845f2f5bce7d061a = function(arg0, arg1, arg2) { const ret = arg0.subarray(arg1 >>> 0, arg2 >>> 0); return ret; }; - imports.wbg.__wbg_then_429f7caf1026411d = function(arg0, arg1, arg2) { - const ret = arg0.then(arg1, arg2); - return ret; - }; imports.wbg.__wbg_then_4f95312d68691235 = function(arg0, arg1) { const ret = arg0.then(arg1); return ret; }; - imports.wbg.__wbg_url_b6d11838a4f95198 = function(arg0, arg1) { - const ret = arg1.url; - const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); - const len1 = WASM_VECTOR_LEN; - getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true); - getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true); - }; - imports.wbg.__wbg_url_df28eef824b04410 = function(arg0, arg1) { - const ret = arg1.url; - const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); - const len1 = WASM_VECTOR_LEN; - getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true); - getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true); - }; - imports.wbg.__wbg_value_57b7b035e117f7ee = function(arg0) { - const ret = arg0.value; - return ret; - }; imports.wbg.__wbg_versions_c01dfd4722a88165 = function(arg0) { const ret = arg0.versions; return ret; @@ -1038,43 +635,19 @@ let wasm_bindgen; const ret = arg0.view; return isLikeNone(ret) ? 0 : addToExternrefTable0(ret); }; - imports.wbg.__wbg_wasClean_4154a2d59fdb4dd7 = function(arg0) { - const ret = arg0.wasClean; - return ret; - }; - imports.wbg.__wbindgen_cast_1a1a598ea8f7755c = function(arg0, arg1) { - // Cast intrinsic for `Closure(Closure { dtor_idx: 206, function: Function { arguments: [NamedExternref("MessageEvent")], shim_idx: 207, ret: Unit, inner_ret: Some(Unit) }, mutable: false }) -> Externref`. - const ret = makeClosure(arg0, arg1, wasm.wasm_bindgen_ff1081b5ed996cf4___closure__destroy___dyn_core_b125d98f3949a913___ops__function__Fn__web_sys_2f551ca386a5254a___features__gen_MessageEvent__MessageEvent____Output_______, wasm_bindgen_ff1081b5ed996cf4___convert__closures_____invoke___web_sys_2f551ca386a5254a___features__gen_MessageEvent__MessageEvent_____); - return ret; - }; imports.wbg.__wbindgen_cast_2241b6af4c4b2941 = function(arg0, arg1) { // Cast intrinsic for `Ref(String) -> Externref`. const ret = getStringFromWasm0(arg0, arg1); return ret; }; - imports.wbg.__wbindgen_cast_26e84dab8c070084 = function(arg0, arg1) { - // Cast intrinsic for `Closure(Closure { dtor_idx: 3468, function: Function { arguments: [], shim_idx: 3469, ret: Unit, inner_ret: Some(Unit) }, mutable: false }) -> Externref`. - const ret = makeClosure(arg0, arg1, wasm.wasm_bindgen_ff1081b5ed996cf4___closure__destroy___dyn_core_b125d98f3949a913___ops__function__Fn_____Output_______, wasm_bindgen_ff1081b5ed996cf4___convert__closures_____invoke______); - return ret; - }; - imports.wbg.__wbindgen_cast_6ab2139490da380b = function(arg0, arg1) { - // Cast intrinsic for `Closure(Closure { dtor_idx: 5276, function: Function { arguments: [Externref], shim_idx: 5277, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. + imports.wbg.__wbindgen_cast_5695985a3f63a81e = function(arg0, arg1) { + // Cast intrinsic for `Closure(Closure { dtor_idx: 956, function: Function { arguments: [Externref], shim_idx: 957, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. const ret = makeMutClosure(arg0, arg1, wasm.wasm_bindgen_ff1081b5ed996cf4___closure__destroy___dyn_core_b125d98f3949a913___ops__function__FnMut__wasm_bindgen_ff1081b5ed996cf4___JsValue____Output_______, wasm_bindgen_ff1081b5ed996cf4___convert__closures_____invoke___wasm_bindgen_ff1081b5ed996cf4___JsValue_____); return ret; }; - imports.wbg.__wbindgen_cast_6f3627d5a130f4e7 = function(arg0, arg1) { - // Cast intrinsic for `Closure(Closure { dtor_idx: 3966, function: Function { arguments: [NamedExternref("MessageEvent")], shim_idx: 3967, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. - const ret = makeMutClosure(arg0, arg1, wasm.wasm_bindgen_ff1081b5ed996cf4___closure__destroy___dyn_core_b125d98f3949a913___ops__function__FnMut__web_sys_2f551ca386a5254a___features__gen_MessageEvent__MessageEvent____Output_______, wasm_bindgen_ff1081b5ed996cf4___convert__closures_____invoke___web_sys_2f551ca386a5254a___features__gen_MessageEvent__MessageEvent______1_); - return ret; - }; - imports.wbg.__wbindgen_cast_b4adb87c8017eb14 = function(arg0, arg1) { - // Cast intrinsic for `Closure(Closure { dtor_idx: 5096, function: Function { arguments: [], shim_idx: 5097, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. - const ret = makeMutClosure(arg0, arg1, wasm.wasm_bindgen_ff1081b5ed996cf4___closure__destroy___dyn_core_b125d98f3949a913___ops__function__FnMut_____Output________1_, wasm_bindgen_ff1081b5ed996cf4___convert__closures_____invoke_______2_); - return ret; - }; - imports.wbg.__wbindgen_cast_bebec89f3b1bff9e = function(arg0, arg1) { - // Cast intrinsic for `Closure(Closure { dtor_idx: 4983, function: Function { arguments: [], shim_idx: 4984, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. - const ret = makeMutClosure(arg0, arg1, wasm.wasm_bindgen_ff1081b5ed996cf4___closure__destroy___dyn_core_b125d98f3949a913___ops__function__FnMut_____Output_______, wasm_bindgen_ff1081b5ed996cf4___convert__closures_____invoke_______1_); + imports.wbg.__wbindgen_cast_8e598183f7f23db7 = function(arg0, arg1) { + // Cast intrinsic for `Closure(Closure { dtor_idx: 128, function: Function { arguments: [NamedExternref("MessageEvent")], shim_idx: 129, ret: Unit, inner_ret: Some(Unit) }, mutable: false }) -> Externref`. + const ret = makeClosure(arg0, arg1, wasm.wasm_bindgen_ff1081b5ed996cf4___closure__destroy___dyn_core_b125d98f3949a913___ops__function__Fn__web_sys_8097425c49dd63ff___features__gen_MessageEvent__MessageEvent____Output_______, wasm_bindgen_ff1081b5ed996cf4___convert__closures_____invoke___web_sys_8097425c49dd63ff___features__gen_MessageEvent__MessageEvent_____); return ret; }; imports.wbg.__wbindgen_cast_cb9088102bce6b30 = function(arg0, arg1) { @@ -1082,11 +655,6 @@ let wasm_bindgen; const ret = getArrayU8FromWasm0(arg0, arg1); return ret; }; - imports.wbg.__wbindgen_cast_ed928a0ca040cecc = function(arg0, arg1) { - // Cast intrinsic for `Closure(Closure { dtor_idx: 3415, function: Function { arguments: [NamedExternref("CloseEvent")], shim_idx: 3416, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. - const ret = makeMutClosure(arg0, arg1, wasm.wasm_bindgen_ff1081b5ed996cf4___closure__destroy___dyn_core_b125d98f3949a913___ops__function__FnMut__web_sys_2f551ca386a5254a___features__gen_CloseEvent__CloseEvent____Output_______, wasm_bindgen_ff1081b5ed996cf4___convert__closures_____invoke___web_sys_2f551ca386a5254a___features__gen_CloseEvent__CloseEvent_____); - return ret; - }; imports.wbg.__wbindgen_init_externref_table = function() { const table = wasm.__wbindgen_externrefs; const offset = table.grow(4); diff --git a/crates/weaver-app/public/embed_worker.js b/crates/weaver-app/public/embed_worker.js index 8ceef98..99456d1 100644 --- a/crates/weaver-app/public/embed_worker.js +++ b/crates/weaver-app/public/embed_worker.js @@ -232,14 +232,6 @@ let wasm_bindgen; let WASM_VECTOR_LEN = 0; - function wasm_bindgen_ff1081b5ed996cf4___convert__closures_____invoke_______1_(arg0, arg1) { - wasm.wasm_bindgen_ff1081b5ed996cf4___convert__closures_____invoke_______1_(arg0, arg1); - } - - function wasm_bindgen_ff1081b5ed996cf4___convert__closures_____invoke___web_sys_2f551ca386a5254a___features__gen_MessageEvent__MessageEvent_____(arg0, arg1, arg2) { - wasm.wasm_bindgen_ff1081b5ed996cf4___convert__closures_____invoke___web_sys_2f551ca386a5254a___features__gen_MessageEvent__MessageEvent_____(arg0, arg1, arg2); - } - function wasm_bindgen_ff1081b5ed996cf4___convert__closures_____invoke______(arg0, arg1) { wasm.wasm_bindgen_ff1081b5ed996cf4___convert__closures_____invoke______(arg0, arg1); } @@ -248,6 +240,14 @@ let wasm_bindgen; wasm.wasm_bindgen_ff1081b5ed996cf4___convert__closures_____invoke___wasm_bindgen_ff1081b5ed996cf4___JsValue_____(arg0, arg1, arg2); } + function wasm_bindgen_ff1081b5ed996cf4___convert__closures_____invoke___web_sys_8097425c49dd63ff___features__gen_MessageEvent__MessageEvent_____(arg0, arg1, arg2) { + wasm.wasm_bindgen_ff1081b5ed996cf4___convert__closures_____invoke___web_sys_8097425c49dd63ff___features__gen_MessageEvent__MessageEvent_____(arg0, arg1, arg2); + } + + function wasm_bindgen_ff1081b5ed996cf4___convert__closures_____invoke_______1_(arg0, arg1) { + wasm.wasm_bindgen_ff1081b5ed996cf4___convert__closures_____invoke_______1_(arg0, arg1); + } + function wasm_bindgen_ff1081b5ed996cf4___convert__closures_____invoke___wasm_bindgen_ff1081b5ed996cf4___JsValue__wasm_bindgen_ff1081b5ed996cf4___JsValue_____(arg0, arg1, arg2, arg3) { wasm.wasm_bindgen_ff1081b5ed996cf4___convert__closures_____invoke___wasm_bindgen_ff1081b5ed996cf4___JsValue__wasm_bindgen_ff1081b5ed996cf4___JsValue_____(arg0, arg1, arg2, arg3); } @@ -796,14 +796,9 @@ let wasm_bindgen; const ret = arg0.view; return isLikeNone(ret) ? 0 : addToExternrefTable0(ret); }; - imports.wbg.__wbindgen_cast_02bff2294fb8559b = function(arg0, arg1) { - // Cast intrinsic for `Closure(Closure { dtor_idx: 1669, function: Function { arguments: [], shim_idx: 1670, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. - const ret = makeMutClosure(arg0, arg1, wasm.wasm_bindgen_ff1081b5ed996cf4___closure__destroy___dyn_core_b125d98f3949a913___ops__function__FnMut_____Output________1_, wasm_bindgen_ff1081b5ed996cf4___convert__closures_____invoke_______1_); - return ret; - }; - imports.wbg.__wbindgen_cast_04f3c1b8909cc857 = function(arg0, arg1) { - // Cast intrinsic for `Closure(Closure { dtor_idx: 636, function: Function { arguments: [NamedExternref("MessageEvent")], shim_idx: 637, ret: Unit, inner_ret: Some(Unit) }, mutable: false }) -> Externref`. - const ret = makeClosure(arg0, arg1, wasm.wasm_bindgen_ff1081b5ed996cf4___closure__destroy___dyn_core_b125d98f3949a913___ops__function__Fn__web_sys_2f551ca386a5254a___features__gen_MessageEvent__MessageEvent____Output_______, wasm_bindgen_ff1081b5ed996cf4___convert__closures_____invoke___web_sys_2f551ca386a5254a___features__gen_MessageEvent__MessageEvent_____); + imports.wbg.__wbindgen_cast_15fea7dce706ecd8 = function(arg0, arg1) { + // Cast intrinsic for `Closure(Closure { dtor_idx: 1458, function: Function { arguments: [], shim_idx: 1459, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. + const ret = makeMutClosure(arg0, arg1, wasm.wasm_bindgen_ff1081b5ed996cf4___closure__destroy___dyn_core_b125d98f3949a913___ops__function__FnMut_____Output_______, wasm_bindgen_ff1081b5ed996cf4___convert__closures_____invoke______); return ret; }; imports.wbg.__wbindgen_cast_2241b6af4c4b2941 = function(arg0, arg1) { @@ -811,14 +806,19 @@ let wasm_bindgen; const ret = getStringFromWasm0(arg0, arg1); return ret; }; - imports.wbg.__wbindgen_cast_d4c1ebc0303a638a = function(arg0, arg1) { - // Cast intrinsic for `Closure(Closure { dtor_idx: 2491, function: Function { arguments: [Externref], shim_idx: 2492, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. + imports.wbg.__wbindgen_cast_27edd9ec229cd76d = function(arg0, arg1) { + // Cast intrinsic for `Closure(Closure { dtor_idx: 64, function: Function { arguments: [NamedExternref("MessageEvent")], shim_idx: 65, ret: Unit, inner_ret: Some(Unit) }, mutable: false }) -> Externref`. + const ret = makeClosure(arg0, arg1, wasm.wasm_bindgen_ff1081b5ed996cf4___closure__destroy___dyn_core_b125d98f3949a913___ops__function__Fn__web_sys_8097425c49dd63ff___features__gen_MessageEvent__MessageEvent____Output_______, wasm_bindgen_ff1081b5ed996cf4___convert__closures_____invoke___web_sys_8097425c49dd63ff___features__gen_MessageEvent__MessageEvent_____); + return ret; + }; + imports.wbg.__wbindgen_cast_5ebf193ba0482380 = function(arg0, arg1) { + // Cast intrinsic for `Closure(Closure { dtor_idx: 2543, function: Function { arguments: [Externref], shim_idx: 2544, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. const ret = makeMutClosure(arg0, arg1, wasm.wasm_bindgen_ff1081b5ed996cf4___closure__destroy___dyn_core_b125d98f3949a913___ops__function__FnMut__wasm_bindgen_ff1081b5ed996cf4___JsValue____Output_______, wasm_bindgen_ff1081b5ed996cf4___convert__closures_____invoke___wasm_bindgen_ff1081b5ed996cf4___JsValue_____); return ret; }; - imports.wbg.__wbindgen_cast_d70ef6992e943fc2 = function(arg0, arg1) { - // Cast intrinsic for `Closure(Closure { dtor_idx: 1406, function: Function { arguments: [], shim_idx: 1407, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. - const ret = makeMutClosure(arg0, arg1, wasm.wasm_bindgen_ff1081b5ed996cf4___closure__destroy___dyn_core_b125d98f3949a913___ops__function__FnMut_____Output_______, wasm_bindgen_ff1081b5ed996cf4___convert__closures_____invoke______); + imports.wbg.__wbindgen_cast_77c2bc8daf69132e = function(arg0, arg1) { + // Cast intrinsic for `Closure(Closure { dtor_idx: 1721, function: Function { arguments: [], shim_idx: 1722, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. + const ret = makeMutClosure(arg0, arg1, wasm.wasm_bindgen_ff1081b5ed996cf4___closure__destroy___dyn_core_b125d98f3949a913___ops__function__FnMut_____Output________1_, wasm_bindgen_ff1081b5ed996cf4___convert__closures_____invoke_______1_); return ret; }; imports.wbg.__wbindgen_init_externref_table = function() { diff --git a/crates/weaver-app/src/components/editor/collab.rs b/crates/weaver-app/src/components/editor/collab.rs index efb6406..b9211b1 100644 --- a/crates/weaver-app/src/components/editor/collab.rs +++ b/crates/weaver-app/src/components/editor/collab.rs @@ -83,7 +83,6 @@ enum CoordinatorState { pub fn CollabCoordinator(props: CollabCoordinatorProps) -> Element { #[cfg(target_arch = "wasm32")] { - use super::worker::{WorkerInput, WorkerOutput}; use crate::collab_context::CollabDebugState; use crate::fetch::Fetcher; use futures_util::stream::SplitSink; @@ -92,8 +91,7 @@ pub fn CollabCoordinator(props: CollabCoordinatorProps) -> Element { use gloo_worker::reactor::ReactorBridge; use jacquard::IntoStatic; use weaver_common::WeaverExt; - - use super::worker::EditorReactor; + use weaver_editor_crdt::{EditorReactor, WorkerInput, WorkerOutput}; let fetcher = use_context::(); diff --git a/crates/weaver-app/src/components/editor/component.rs b/crates/weaver-app/src/components/editor/component.rs index 8527324..fef4a57 100644 --- a/crates/weaver-app/src/components/editor/component.rs +++ b/crates/weaver-app/src/components/editor/component.rs @@ -501,7 +501,7 @@ fn MarkdownEditorInner( // Background fetch for AT embeds via worker #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] { - use super::worker::{EmbedWorker, EmbedWorkerInput, EmbedWorkerOutput}; + use weaver_embed_worker::{EmbedWorker, EmbedWorkerInput, EmbedWorkerOutput}; use dioxus::prelude::Writable; use gloo_worker::Spawnable; @@ -731,7 +731,7 @@ fn MarkdownEditorInner( // Worker-based autosave (offloads export + encode to worker thread) #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] { - use super::worker::{EditorReactor, WorkerInput, WorkerOutput}; + use weaver_editor_crdt::{EditorReactor, WorkerInput, WorkerOutput}; use gloo_storage::Storage; use gloo_worker::Spawnable; use gloo_worker::reactor::ReactorBridge; @@ -964,7 +964,7 @@ fn MarkdownEditorInner( tracing::debug!(input_type = %input_type_str, "beforeinput"); let plat = platform::platform(); - let input_type = InputType::from_str(&input_type_str); + let input_type = weaver_editor_browser::parse_browser_input_type(&input_type_str); let is_composing = evt.is_composing(); // Get target range from the event if available @@ -1444,7 +1444,6 @@ fn MarkdownEditorInner( let _ = crate::components::editor::cursor::restore_cursor_position( offset, &map, - editor_id, None, ); return; diff --git a/crates/weaver-app/src/components/editor/document.rs b/crates/weaver-app/src/components/editor/document.rs index 9e44d17..7c07cd9 100644 --- a/crates/weaver-app/src/components/editor/document.rs +++ b/crates/weaver-app/src/components/editor/document.rs @@ -1170,3 +1170,46 @@ impl PartialEq for EditorDocument { false } } + +impl weaver_editor_crdt::CrdtDocument for EditorDocument { + fn export_snapshot(&self) -> Vec { + self.export_snapshot() + } + + fn export_updates_since_sync(&self) -> Option> { + self.export_updates_since_sync() + } + + fn import(&mut self, data: &[u8]) -> Result<(), weaver_editor_crdt::CrdtError> { + self.import_updates(data) + .map_err(|e| weaver_editor_crdt::CrdtError::Import(e.to_string())) + } + + fn version(&self) -> VersionVector { + self.version_vector() + } + + fn edit_root(&self) -> Option> { + self.edit_root() + } + + fn set_edit_root(&mut self, root: Option>) { + self.set_edit_root(root); + } + + fn last_diff(&self) -> Option> { + self.last_diff() + } + + fn set_last_diff(&mut self, diff: Option>) { + self.set_last_diff(diff); + } + + fn mark_synced(&mut self) { + self.mark_synced(); + } + + fn has_unsynced_changes(&self) -> bool { + self.has_unsynced_changes() + } +} diff --git a/crates/weaver-app/src/components/editor/dom_sync.rs b/crates/weaver-app/src/components/editor/dom_sync.rs index 778df02..5ec1794 100644 --- a/crates/weaver-app/src/components/editor/dom_sync.rs +++ b/crates/weaver-app/src/components/editor/dom_sync.rs @@ -213,7 +213,7 @@ pub fn update_paragraph_dom( let is_cursor_para = new_para.char_range.start <= cursor_offset && cursor_offset <= new_para.char_range.end; - if let Some(existing_elem) = old_elements.remove(para_id) { + if let Some(existing_elem) = old_elements.remove(para_id.as_str()) { // Element exists - check if it needs updating let old_hash = existing_elem.get_attribute("data-hash").unwrap_or_default(); let needs_update = force || old_hash != new_hash; @@ -227,7 +227,7 @@ pub fn update_paragraph_dom( if !at_correct_position { tracing::warn!( - para_id, + para_id = %para_id, is_cursor_para, "update_paragraph_dom: element not at correct position, moving" ); @@ -286,7 +286,7 @@ pub fn update_paragraph_dom( if should_skip_cursor_update { tracing::trace!( - para_id, + para_id = %para_id, "update_paragraph_dom: skipping cursor para innerHTML (syntax unchanged, DOM verified)" ); // Update hash - browser native editing has the correct content @@ -318,7 +318,7 @@ pub fn update_paragraph_dom( { let elapsed_ms = end_time - start_time; tracing::debug!( - para_id, + para_id = %para_id, is_cursor_para, elapsed_ms, html_len = new_para.html.len(), @@ -335,7 +335,6 @@ pub fn update_paragraph_dom( if let Err(e) = restore_cursor_position( cursor_offset, &new_para.offset_map, - editor_id, None, ) { tracing::warn!("Synchronous cursor restore failed: {:?}", e); diff --git a/crates/weaver-app/src/components/editor/mod.rs b/crates/weaver-app/src/components/editor/mod.rs index cbd6a5c..56c48a1 100644 --- a/crates/weaver-app/src/components/editor/mod.rs +++ b/crates/weaver-app/src/components/editor/mod.rs @@ -24,8 +24,6 @@ mod storage; mod sync; mod toolbar; mod visibility; -#[cfg(all(target_family = "wasm", target_os = "unknown"))] -mod worker; mod writer; #[cfg(test)] @@ -96,9 +94,10 @@ pub use visibility::VisibilityState; #[allow(unused_imports)] pub use log_buffer::LogCaptureLayer; -// Worker - EditorReactor stays local, EmbedWorker comes from weaver-embed-worker +// Worker types from weaver-editor-crdt #[cfg(all(target_family = "wasm", target_os = "unknown"))] -pub use worker::{EditorReactor, WorkerInput, WorkerOutput}; +pub use weaver_editor_crdt::{EditorReactor, WorkerInput, WorkerOutput}; +// Embed worker from weaver-embed-worker #[cfg(all(target_family = "wasm", target_os = "unknown"))] pub use weaver_embed_worker::{EmbedWorker, EmbedWorkerInput, EmbedWorkerOutput}; diff --git a/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__unicode_emoji.snap b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__unicode_emoji.snap index 4faf845..39532b6 100644 --- a/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__unicode_emoji.snap +++ b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__unicode_emoji.snap @@ -8,7 +8,7 @@ expression: result char_range: - 0 - 13 - html: "

Hello 🎉 world

\n" + html: "

Hello 🎉 world

" offset_map: - byte_range: - 0 diff --git a/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__unordered_list.snap b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__unordered_list.snap index d8992ee..59ad6d9 100644 --- a/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__unordered_list.snap +++ b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__unordered_list.snap @@ -8,7 +8,7 @@ expression: result char_range: - 0 - 26 - html: "
    \n
  • - Item 1\n
  • \n
  • - Item 2\n
  • \n
  • - Item 3
  • \n
\n" + html: "
  • - Item 1\n
  • - Item 2\n
  • - Item 3
" offset_map: - byte_range: - 0 diff --git a/crates/weaver-app/src/components/editor/sync.rs b/crates/weaver-app/src/components/editor/sync.rs index 0cef69d..518d4c2 100644 --- a/crates/weaver-app/src/components/editor/sync.rs +++ b/crates/weaver-app/src/components/editor/sync.rs @@ -1,56 +1,33 @@ //! PDS synchronization for editor edit state. //! -//! This module handles syncing the editor's Loro CRDT document to AT Protocol -//! edit records (`sh.weaver.edit.root` and `sh.weaver.edit.diff`). -//! -//! ## Edit State Structure -//! -//! - `sh.weaver.edit.root`: The starting point for an edit session, containing -//! a full Loro snapshot and a reference to the entry being edited. -//! - `sh.weaver.edit.diff`: Incremental updates since the root (or previous diff), -//! containing only the Loro delta bytes. -//! -//! ## Sync Flow -//! -//! 1. **First sync**: Create a root record with a full snapshot -//! 2. **Subsequent syncs**: Create diff records with deltas since last sync -//! 3. **Loading**: Find root via constellation backlinks, fetch all diffs, apply in order +//! This module provides app-specific sync functionality built on top of +//! `weaver_editor_crdt::sync`. It adds: +//! - Fetcher-based API (wrapping the generic client) +//! - Embed prefetching and blob caching +//! - localStorage integration for document loading +//! - Dioxus UI components for sync status -use std::collections::BTreeMap; +use std::collections::HashMap; use super::document::{EditorDocument, LoadedDocState}; use crate::fetch::Fetcher; -use jacquard::bytes::Bytes; -use jacquard::cowstr::ToCowStr; +use jacquard::IntoStatic; use jacquard::prelude::*; -use jacquard::smol_str::format_smolstr; -use jacquard::types::blob::MimeType; -#[allow(unused_imports)] -use jacquard::types::collection::Collection; use jacquard::types::ident::AtIdentifier; -use jacquard::types::recordkey::RecordKey; -use jacquard::types::string::{AtUri, Cid, Did, Nsid}; -use jacquard::types::tid::Ticker; -use jacquard::types::uri::Uri; -use jacquard::url::Url; -use jacquard::{CowStr, IntoStatic, to_data}; +use jacquard::types::string::{AtUri, Cid, Did}; use loro::LoroDoc; use loro::ToJson; -use weaver_api::com_atproto::repo::create_record::CreateRecord; use weaver_api::com_atproto::repo::strong_ref::StrongRef; -use weaver_api::com_atproto::sync::get_blob::GetBlob; -use weaver_api::sh_weaver::edit::diff::Diff; use weaver_api::sh_weaver::edit::draft::Draft; -use weaver_api::sh_weaver::edit::root::Root; -use weaver_api::sh_weaver::edit::{DocRef, DocRefValue, DraftRef, EntryRef}; -use weaver_common::constellation::{GetBacklinksQuery, RecordId}; -#[allow(unused_imports)] +use weaver_api::sh_weaver::edit::{DocRef, DocRefValue}; use weaver_common::{WeaverError, WeaverExt}; -const ROOT_NSID: &str = "sh.weaver.edit.root"; -const DIFF_NSID: &str = "sh.weaver.edit.diff"; -const DRAFT_NSID: &str = "sh.weaver.edit.draft"; -const CONSTELLATION_URL: &str = "https://constellation.microcosm.blue"; +// Re-export crdt sync types for convenience. +pub use weaver_editor_crdt::{ + CreateRootResult, PdsEditState, RemoteDraft, SyncResult, build_draft_uri, find_all_edit_roots, + find_diffs_for_root, find_edit_root_for_draft, list_drafts, load_all_edit_states, + load_edit_state_from_draft, load_edit_state_from_entry, +}; /// Extract record embeds from a LoroDoc and pre-fetch their rendered content. /// @@ -182,65 +159,6 @@ async fn prefetch_embeds_from_doc( resolved } -/// Build a DocRef for either a published entry or an unpublished draft. -/// -/// If entry_uri and entry_cid are provided, creates an EntryRef. -/// Otherwise, creates a DraftRef with a synthetic AT-URI for Constellation indexing. -/// -/// The synthetic URI format is: `at://{did}/sh.weaver.edit.draft/{rkey}` -/// This allows Constellation to index drafts as backlinks, enabling discovery. -fn build_doc_ref( - did: &Did<'_>, - draft_key: &str, - entry_uri: Option<&AtUri<'_>>, - entry_cid: Option<&Cid<'_>>, -) -> DocRef<'static> { - match (entry_uri, entry_cid) { - (Some(uri), Some(cid)) => DocRef { - value: DocRefValue::EntryRef(Box::new(EntryRef { - entry: StrongRef::new() - .uri(uri.clone().into_static()) - .cid(cid.clone().into_static()) - .build(), - extra_data: None, - })), - extra_data: None, - }, - _ => { - // Transform localStorage key to synthetic AT-URI for Constellation indexing - // localStorage uses "new:{tid}" or AT-URI, PDS uses "at://{did}/sh.weaver.edit.draft/{rkey}" - let rkey = if let Some(tid) = draft_key.strip_prefix("new:") { - // New draft: extract TID as rkey - tid.to_string() - } else if draft_key.starts_with("at://") { - // Editing existing entry: use the entry's rkey - draft_key.split('/').last().unwrap_or(draft_key).to_string() - } else if draft_key.starts_with("did:") && draft_key.contains(':') { - // Old canonical format "did:xxx:rkey" - extract rkey - draft_key - .rsplit(':') - .next() - .unwrap_or(draft_key) - .to_string() - } else { - // Fallback: use as-is - draft_key.to_string() - }; - - // Build AT-URI pointing to actual draft record: at://{did}/sh.weaver.edit.draft/{rkey} - let canonical_uri = format_smolstr!("at://{}/{}/{}", did, DRAFT_NSID, rkey); - - DocRef { - value: DocRefValue::DraftRef(Box::new(DraftRef { - draft_key: CowStr::from(canonical_uri), - extra_data: None, - })), - extra_data: None, - } - } - } -} - /// Convert a DocRef to an entry_ref StrongRef. /// /// For EntryRef: returns the entry's StrongRef directly @@ -277,413 +195,23 @@ async fn doc_ref_to_entry_ref( } } -/// Result of a sync operation. -#[derive(Clone, Debug)] -pub enum SyncResult { - /// Created a new root record (first sync) - CreatedRoot { - uri: AtUri<'static>, - cid: Cid<'static>, - }, - /// Created a new diff record - CreatedDiff { - uri: AtUri<'static>, - cid: Cid<'static>, - }, - /// No changes to sync - NoChanges, -} - -/// Find ALL edit.root records across collaborators for an entry. -/// -/// With use-index: Uses weaver-index getEditHistory endpoint. -/// Without use-index: Queries Constellation for backlinks. -#[cfg(feature = "use-index")] -pub async fn find_all_edit_roots_for_entry( - fetcher: &Fetcher, - entry_uri: &AtUri<'_>, -) -> Result>, WeaverError> { - let output = fetcher - .get_edit_history(entry_uri) - .await - .map_err(|e| WeaverError::InvalidNotebook(format!("Failed to get edit history: {}", e)))?; - - // Convert EditHistoryEntry roots to RecordId format - let roots: Vec> = output - .roots - .into_iter() - .filter_map(|entry| { - let uri = AtUri::new(entry.uri.as_ref()).ok()?; - let did = match uri.authority() { - AtIdentifier::Did(d) => d.clone().into_static(), - _ => return None, - }; - let rkey = uri.rkey()?.clone().into_static(); - Some(RecordId { - did, - collection: Nsid::raw(ROOT_NSID).into_static(), - rkey, - }) - }) - .collect(); - - tracing::debug!( - "find_all_edit_roots_for_entry (index): found {} roots", - roots.len() - ); - - Ok(roots) -} - -/// Find ALL edit.root records across collaborators for an entry. -/// -/// 1. Gets list of collaborators via permissions -/// 2. Queries Constellation for edit.root in each collaborator's repo -/// 3. Returns all found roots for CRDT merge -#[cfg(not(feature = "use-index"))] -pub async fn find_all_edit_roots_for_entry( - fetcher: &Fetcher, - entry_uri: &AtUri<'_>, -) -> Result>, WeaverError> { - // Get collaborators from permissions - let collaborators = fetcher - .get_client() - .find_collaborators_for_resource(entry_uri) - .await - .unwrap_or_default(); - - // Include the entry owner - let owner_did = match entry_uri.authority() { - AtIdentifier::Did(d) => d.clone().into_static(), - AtIdentifier::Handle(h) => fetcher - .client - .resolve_handle(h) - .await - .map_err(|e| WeaverError::InvalidNotebook(format!("Failed to resolve handle: {}", e)))? - .into_static(), - }; - - let all_dids: Vec> = std::iter::once(owner_did) - .chain(collaborators.into_iter()) - .collect(); - - let constellation_url = Url::parse(CONSTELLATION_URL) - .map_err(|e| WeaverError::InvalidNotebook(format!("Invalid constellation URL: {}", e)))?; - - let mut all_roots = Vec::new(); - - // Query for edit.root records from this DID that reference entry_uri - let query = GetBacklinksQuery { - subject: Uri::At(entry_uri.clone().into_static()), - source: format_smolstr!("{}:doc.value.entry.uri", ROOT_NSID).into(), - cursor: None, - did: all_dids.clone(), - limit: 10, - }; - - let response = fetcher - .get_client() - .xrpc(constellation_url.clone()) - .send(&query) - .await; - - if let Ok(response) = response { - if let Ok(output) = response.into_output() { - all_roots.extend(output.records.into_iter().map(|r| r.into_static())); - } else { - tracing::warn!("Failed to parse response for edit root query"); - } - } else { - tracing::warn!("Failed to fetch edit root query"); - } - - tracing::debug!( - "find_all_edit_roots_for_entry: found {} roots across {} collaborators", - all_roots.len(), - all_dids.len() - ); - - Ok(all_roots) -} - -/// Find the edit root for a draft using constellation backlinks. -/// -/// Queries constellation for `sh.weaver.edit.root` records that reference -/// the given draft URI via the `.doc.value.draft_key` path. -/// -/// The draft_uri should be in canonical format: `at://{did}/sh.weaver.edit.draft/{rkey}` -pub async fn find_edit_root_for_draft( - fetcher: &Fetcher, - draft_uri: &AtUri<'_>, -) -> Result>, WeaverError> { - let constellation_url = Url::parse(CONSTELLATION_URL) - .map_err(|e| WeaverError::InvalidNotebook(format!("Invalid constellation URL: {}", e)))?; - - let query = GetBacklinksQuery { - subject: Uri::At(draft_uri.clone().into_static()), - source: format_smolstr!("{}:doc.value.draft_key", ROOT_NSID).into(), - cursor: None, - did: vec![], - limit: 1, - }; - - let response = fetcher - .client - .xrpc(constellation_url) - .send(&query) - .await - .map_err(|e| WeaverError::InvalidNotebook(format!("Constellation query failed: {}", e)))?; - - let output = response.into_output().map_err(|e| { - WeaverError::InvalidNotebook(format!("Failed to parse constellation response: {}", e)) - })?; - - Ok(output.records.into_iter().next().map(|r| r.into_static())) -} - -/// Build a canonical draft URI from localStorage key and DID. -/// -/// Transforms localStorage format ("new:{tid}" or AT-URI) to -/// draft record URI format: `at://{did}/sh.weaver.edit.draft/{rkey}` -pub fn build_draft_uri(did: &Did<'_>, draft_key: &str) -> AtUri<'static> { - let rkey = if let Some(tid) = draft_key.strip_prefix("new:") { - tid.to_string() - } else if draft_key.starts_with("at://") { - draft_key.split('/').last().unwrap_or(draft_key).to_string() - } else { - draft_key.to_string() - }; - - let uri_str = format_smolstr!("at://{}/{}/{}", did, DRAFT_NSID, rkey); - // Safe to unwrap: we're constructing a valid AT-URI - AtUri::new(&uri_str).unwrap().into_static() -} - -/// Extract the rkey (TID) from a localStorage draft key. -fn extract_draft_rkey(draft_key: &str) -> String { - if let Some(tid) = draft_key.strip_prefix("new:") { - tid.to_string() - } else if draft_key.starts_with("at://") { - draft_key.split('/').last().unwrap_or(draft_key).to_string() - } else { - draft_key.to_string() - } -} - -/// Create the draft stub record on PDS. -/// -/// This creates a minimal `sh.weaver.edit.draft` record that acts as an anchor -/// for edit.root/diff records and enables draft discovery via listRecords. -async fn create_draft_stub( - fetcher: &Fetcher, - did: &Did<'_>, - rkey: &str, -) -> Result<(AtUri<'static>, Cid<'static>), WeaverError> { - // Build minimal draft record with just createdAt - let draft = Draft::new() - .created_at(jacquard::types::datetime::Datetime::now()) - .build(); - - let draft_data = to_data(&draft) - .map_err(|e| WeaverError::InvalidNotebook(format!("Failed to serialize draft: {}", e)))?; - - let record_key = - RecordKey::any(rkey).map_err(|e| WeaverError::InvalidNotebook(e.to_string()))?; - - let collection = Nsid::new(DRAFT_NSID).map_err(WeaverError::AtprotoString)?; - - let request = CreateRecord::new() - .repo(AtIdentifier::Did(did.clone().into_static())) - .collection(collection) - .rkey(record_key) - .record(draft_data) - .build(); - - let response = fetcher - .send(request) - .await - .map_err(jacquard::client::AgentError::from)?; - - let output = response - .into_output() - .map_err(|e| WeaverError::InvalidNotebook(e.to_string()))?; - - Ok((output.uri.into_static(), output.cid.into_static())) -} - -/// Remote draft info from PDS. -#[derive(Clone, Debug)] -pub struct RemoteDraft { - /// The draft record URI - pub uri: AtUri<'static>, - /// The rkey (TID) of the draft - pub rkey: String, - /// When the draft was created - pub created_at: String, -} - -/// List all drafts for the current user. -/// -/// With use-index: Uses weaver-index listDrafts endpoint. -/// Without use-index: Uses direct PDS ListRecords query. -#[cfg(feature = "use-index")] -pub async fn list_drafts_from_pds(fetcher: &Fetcher) -> Result, WeaverError> { - let did = fetcher - .current_did() - .await - .ok_or_else(|| WeaverError::InvalidNotebook("Not authenticated".into()))?; - - let actor = AtIdentifier::Did(did); - let output = fetcher - .list_drafts(&actor) - .await - .map_err(|e| WeaverError::InvalidNotebook(format!("Failed to list drafts: {}", e)))?; - - tracing::debug!( - "list_drafts_from_pds (index): found {} drafts", - output.drafts.len() - ); - - let drafts = output - .drafts - .into_iter() - .filter_map(|draft| { - let uri = AtUri::new(draft.uri.as_ref()).ok()?.into_static(); - let rkey = uri.rkey()?.0.as_str().to_string(); - let created_at = draft.created_at.to_string(); - Some(RemoteDraft { - uri, - rkey, - created_at, - }) - }) - .collect(); - - Ok(drafts) -} - /// List all drafts from PDS for the current user. /// -/// Returns a list of draft records from `sh.weaver.edit.draft` collection. -#[cfg(not(feature = "use-index"))] +/// Wraps the crdt crate's list_drafts function with Fetcher support. pub async fn list_drafts_from_pds(fetcher: &Fetcher) -> Result, WeaverError> { - use weaver_api::com_atproto::repo::list_records::ListRecords; - let did = fetcher .current_did() .await .ok_or_else(|| WeaverError::InvalidNotebook("Not authenticated".into()))?; - let client = fetcher.get_client(); - let collection = Nsid::new(DRAFT_NSID).map_err(WeaverError::AtprotoString)?; - - let request = ListRecords::new() - .repo(did) - .collection(collection) - .limit(100) - .build(); - - let response = client - .send(request) + list_drafts(fetcher.get_client().as_ref(), &did) .await - .map_err(|e| WeaverError::InvalidNotebook(format!("Failed to list drafts: {}", e)))?; - - let output = response.into_output().map_err(|e| { - WeaverError::InvalidNotebook(format!("Failed to parse list records response: {}", e)) - })?; - - tracing::debug!( - "list_drafts_from_pds: found {} records", - output.records.len() - ); - - let mut drafts = Vec::new(); - for record in output.records { - let rkey = record - .uri - .rkey() - .map(|r| r.0.as_str().to_string()) - .unwrap_or_default(); - - tracing::debug!(" Draft record: uri={}, rkey={}", record.uri, rkey); - - // Parse the draft record to get createdAt - let created_at = - jacquard::from_data::(&record.value) - .map(|d| d.created_at.to_string()) - .unwrap_or_default(); - - drafts.push(RemoteDraft { - uri: record.uri.into_static(), - rkey, - created_at, - }); - } - - Ok(drafts) -} - -/// Find all diffs for a root record using constellation backlinks. -pub async fn find_diffs_for_root( - fetcher: &Fetcher, - root_uri: &AtUri<'_>, -) -> Result>, WeaverError> { - let constellation_url = Url::parse(CONSTELLATION_URL) - .map_err(|e| WeaverError::InvalidNotebook(format!("Invalid constellation URL: {}", e)))?; - - let mut all_diffs = Vec::new(); - let mut cursor: Option = None; - - loop { - let query = GetBacklinksQuery { - subject: Uri::At(root_uri.clone().into_static()), - source: format_smolstr!("{}:root.uri", DIFF_NSID).into(), - cursor: cursor.map(Into::into), - did: vec![], - limit: 100, - }; - - let response = fetcher - .client - .xrpc(constellation_url.clone()) - .send(&query) - .await - .map_err(|e| { - WeaverError::InvalidNotebook(format!("Constellation query failed: {}", e)) - })?; - - let output = response.into_output().map_err(|e| { - WeaverError::InvalidNotebook(format!("Failed to parse constellation response: {}", e)) - })?; - - all_diffs.extend(output.records.into_iter().map(|r| r.into_static())); - - match output.cursor { - Some(c) => cursor = Some(c.to_string()), - None => break, - } - } - - Ok(all_diffs) -} - -/// Result of creating an edit root, includes optional draft stub info. -pub struct CreateRootResult { - /// The root record URI - pub root_uri: AtUri<'static>, - /// The root record CID - pub root_cid: Cid<'static>, - /// Draft stub StrongRef if this was a new draft (not editing published entry) - pub draft_ref: Option>, + .map_err(|e| WeaverError::InvalidNotebook(e.to_string())) } /// Create the edit root record for an entry. /// -/// Uploads the current Loro snapshot as a blob and creates an `sh.weaver.edit.root` -/// record referencing the entry (or draft key if unpublished). -/// -/// For drafts, also creates the `sh.weaver.edit.draft` stub record first. -/// Returns the draft stub info so caller can set entry_ref. +/// Wraps the crdt crate's create_edit_root with Fetcher support. pub async fn create_edit_root( fetcher: &Fetcher, doc: &EditorDocument, @@ -691,114 +219,20 @@ pub async fn create_edit_root( entry_uri: Option<&AtUri<'_>>, entry_cid: Option<&Cid<'_>>, ) -> Result { - let client = fetcher.get_client(); - let did = fetcher - .current_did() - .await - .ok_or_else(|| WeaverError::InvalidNotebook("Not authenticated".into()))?; - - // For drafts, create the stub record first (makes it discoverable via listRecords) - let draft_ref: Option> = if entry_uri.is_none() { - let rkey = extract_draft_rkey(draft_key); - // Try to create draft stub, or get existing one - match create_draft_stub(fetcher, &did, &rkey).await { - Ok((uri, cid)) => { - tracing::debug!("Created draft stub: {}", uri); - Some(StrongRef::new().uri(uri).cid(cid).build()) - } - Err(e) => { - // Check if it's a "record already exists" error - let err_str = e.to_string(); - if err_str.contains("RecordAlreadyExists") || err_str.contains("already exists") { - // Draft exists - fetch it to get the CID - let draft_uri_str = format!("at://{}/{}/{}", did, DRAFT_NSID, rkey); - if let Ok(draft_uri) = AtUri::new(&draft_uri_str) { - if let Ok(response) = - fetcher.get_client().get_record::(&draft_uri).await - { - if let Ok(output) = response.into_output() { - if let Some(cid) = output.cid { - Some( - StrongRef::new() - .uri(draft_uri.into_static()) - .cid(cid.into_static()) - .build(), - ) - } else { - tracing::warn!("Draft exists but has no CID"); - None - } - } else { - tracing::warn!("Draft exists but couldn't parse output"); - None - } - } else { - tracing::warn!("Draft exists but couldn't fetch record"); - None - } - } else { - None - } - } else { - tracing::warn!("Failed to create draft stub (continuing anyway): {}", e); - None - } - } - } - } else { - None // Published entry, not a draft - }; - - // Export full snapshot - let snapshot = doc.export_snapshot(); - - // Upload snapshot blob - let mime_type = MimeType::new_static("application/octet-stream"); - let blob_ref = client - .upload_blob(snapshot, mime_type) - .await - .map_err(|e| WeaverError::InvalidNotebook(format!("Failed to upload snapshot: {}", e)))?; - - // Build DocRef - use EntryRef if published, DraftRef if not - let doc_ref = build_doc_ref(&did, draft_key, entry_uri, entry_cid); - - // Build root record - let root = Root::new().doc(doc_ref).snapshot(blob_ref).build(); - - let root_data = to_data(&root) - .map_err(|e| WeaverError::InvalidNotebook(format!("Failed to serialize root: {}", e)))?; - - // Generate TID for the root rkey - let root_tid = Ticker::new().next(None); - let rkey = RecordKey::any(root_tid.as_str()) - .map_err(|e| WeaverError::InvalidNotebook(e.to_string()))?; - - let collection = Nsid::new(ROOT_NSID).map_err(|e| WeaverError::AtprotoString(e))?; - - let request = CreateRecord::new() - .repo(AtIdentifier::Did(did)) - .collection(collection) - .rkey(rkey) - .record(root_data) - .build(); - - let response = fetcher - .send(request) - .await - .map_err(jacquard::client::AgentError::from)?; - - let output = response - .into_output() - .map_err(|e| WeaverError::InvalidNotebook(e.to_string()))?; - - Ok(CreateRootResult { - root_uri: output.uri.into_static(), - root_cid: output.cid.into_static(), - draft_ref, - }) + weaver_editor_crdt::create_edit_root( + fetcher.get_client().as_ref(), + doc, + draft_key, + entry_uri, + entry_cid, + ) + .await + .map_err(|e| WeaverError::InvalidNotebook(e.to_string())) } /// Create a diff record with updates since the last sync. +/// +/// Wraps the crdt crate's create_diff with Fetcher support. pub async fn create_diff( fetcher: &Fetcher, doc: &EditorDocument, @@ -809,86 +243,18 @@ pub async fn create_diff( entry_uri: Option<&AtUri<'_>>, entry_cid: Option<&Cid<'_>>, ) -> Result, Cid<'static>)>, WeaverError> { - // Export updates since last sync - let updates = match doc.export_updates_since_sync() { - Some(u) => u, - None => return Ok(None), // No changes - }; - - let client = fetcher.get_client(); - let did = fetcher - .current_did() - .await - .ok_or_else(|| WeaverError::InvalidNotebook("Not authenticated".into()))?; - - // 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 { - (None, Some(jacquard::bytes::Bytes::from(updates))) - } else { - 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(&did, draft_key, entry_uri, entry_cid); - - // Build root reference - let root_ref = StrongRef::new() - .uri(root_uri.clone().into_static()) - .cid(root_cid.clone().into_static()) - .build(); - - // Build prev reference if we have a previous diff - let prev_ref = prev_diff.map(|(uri, cid)| { - StrongRef::new() - .uri(uri.clone().into_static()) - .cid(cid.clone().into_static()) - .build() - }); - - // Build diff record - let diff = Diff::new() - .doc(doc_ref) - .root(root_ref) - .maybe_snapshot(blob_ref) - .maybe_inline_diff(inline_diff) - .maybe_prev(prev_ref) - .build(); - - let diff_data = to_data(&diff) - .map_err(|e| WeaverError::InvalidNotebook(format!("Failed to serialize diff: {}", e)))?; - - // Generate TID for the diff rkey - let diff_tid = Ticker::new().next(None); - let rkey = RecordKey::any(diff_tid.as_str()) - .map_err(|e| WeaverError::InvalidNotebook(e.to_string()))?; - - let collection = Nsid::new(DIFF_NSID).map_err(|e| WeaverError::AtprotoString(e))?; - - let request = CreateRecord::new() - .repo(AtIdentifier::Did(did)) - .collection(collection) - .rkey(rkey) - .record(diff_data) - .build(); - - let response = fetcher - .send(request) - .await - .map_err(jacquard::client::AgentError::from)?; - - let output = response - .into_output() - .map_err(|e| WeaverError::InvalidNotebook(e.to_string()))?; - - Ok(Some((output.uri.into_static(), output.cid.into_static()))) + weaver_editor_crdt::create_diff( + fetcher.get_client().as_ref(), + doc, + root_uri, + root_cid, + prev_diff, + draft_key, + entry_uri, + entry_cid, + ) + .await + .map_err(|e| WeaverError::InvalidNotebook(e.to_string())) } /// Sync the document to the PDS. @@ -997,350 +363,52 @@ pub async fn sync_to_pds( } } -/// Result of loading edit state from PDS. -#[derive(Clone, Debug)] -pub struct PdsEditState { - /// The root record reference - pub root_ref: StrongRef<'static>, - /// The latest diff reference (if any diffs exist) - pub last_diff_ref: Option>, - /// The Loro snapshot bytes from the root - pub root_snapshot: Bytes, - /// All diff update bytes in order (oldest first, by TID) - pub diff_updates: Vec, - /// Last seen diff URI per collaborator root (for incremental sync). - /// Maps root URI -> last diff URI we've imported from that root. - pub last_seen_diffs: std::collections::HashMap, AtUri<'static>>, - /// The DocRef from the root record (tells us what's being edited) - pub doc_ref: DocRef<'static>, -} - -/// Fetch a blob from the PDS. -async fn fetch_blob(fetcher: &Fetcher, did: &Did<'_>, cid: &Cid<'_>) -> Result { - let pds_url = fetcher - .client - .pds_for_did(did) - .await - .map_err(|e| WeaverError::InvalidNotebook(format!("Failed to resolve DID: {}", e)))?; - - let request = GetBlob::new().did(did.clone()).cid(cid.clone()).build(); - - let response = fetcher - .client - .xrpc(pds_url) - .send(&request) - .await - .map_err(|e| WeaverError::InvalidNotebook(format!("Failed to fetch blob: {}", e)))?; - - let output = response.into_output().map_err(|e| { - WeaverError::InvalidNotebook(format!("Failed to parse blob response: {}", e)) - })?; - - Ok(output.body) -} - /// Load edit state from the PDS for an entry. /// -/// Finds the edit root via constellation backlinks, fetches all diffs, -/// and returns the snapshot + updates needed to reconstruct the document. +/// Wraps the crdt crate's load_edit_state_from_entry with Fetcher support. pub async fn load_edit_state_from_pds( fetcher: &Fetcher, entry_uri: &AtUri<'_>, ) -> Result, WeaverError> { - // Find the edit root for this entry (take first if multiple exist) - let root_id = match find_all_edit_roots_for_entry(fetcher, entry_uri) - .await? - .into_iter() - .next() - { - Some(id) => id, - None => return Ok(None), - }; - - load_edit_state_from_root_id(fetcher, root_id, None).await -} - -/// Load edit state from the PDS for a draft. -/// -/// Finds the edit root via constellation backlinks using the draft URI, -/// fetches all diffs, and returns the snapshot + updates. -pub async fn load_edit_state_from_draft( - fetcher: &Fetcher, - draft_uri: &AtUri<'_>, -) -> Result, WeaverError> { - // Find the edit root for this draft - let root_id = match find_edit_root_for_draft(fetcher, draft_uri).await? { - Some(id) => id, - None => return Ok(None), - }; + let client = fetcher.get_client(); + // Get collaborators for this resource. + let collaborators = client + .find_collaborators_for_resource(entry_uri) + .await + .unwrap_or_default(); - load_edit_state_from_root_id(fetcher, root_id, None).await + load_edit_state_from_entry(client.as_ref(), entry_uri, collaborators) + .await + .map_err(|e| WeaverError::InvalidNotebook(e.to_string())) } /// Load edit state from ALL collaborator repos for an entry, returning merged state. /// -/// For each edit.root found across collaborators: -/// - Fetches the root snapshot -/// - Finds and fetches all diffs for that root (skipping already-seen diffs) -/// - Merges all Loro states into one unified document -/// -/// `last_seen_diffs` maps root URI -> last diff URI we've imported from that root. -/// This enables incremental sync by only fetching new diffs. -/// -/// Returns merged state suitable for CRDT collaboration, including updated last_seen_diffs. +/// Wraps the crdt crate's load_all_edit_states with Fetcher support. pub async fn load_all_edit_states_from_pds( fetcher: &Fetcher, entry_uri: &AtUri<'_>, - last_seen_diffs: &std::collections::HashMap, AtUri<'static>>, + last_seen_diffs: &HashMap, AtUri<'static>>, ) -> Result, WeaverError> { - let all_roots = find_all_edit_roots_for_entry(fetcher, entry_uri).await?; - - if all_roots.is_empty() { - return Ok(None); - } - - // We'll merge all snapshots and diffs into one unified LoroDoc - let merged_doc = LoroDoc::new(); - let mut our_root_ref: Option> = None; - let mut our_last_diff_ref: Option> = None; - let mut merged_doc_ref: Option> = None; - let mut updated_last_seen = last_seen_diffs.clone(); - - // Get current user's DID to identify "our" root for sync state tracking - let current_did = fetcher.current_did().await; - - for root_id in all_roots { - // Save the DID before consuming root_id - let root_did = root_id.did.clone(); - - // Build root URI to look up last seen diff - let root_uri = AtUri::new(&format_smolstr!( - "at://{}/{}/{}", - root_id.did, - ROOT_NSID, - root_id.rkey.as_ref() - )) - .ok() - .map(|u| u.into_static()); - - // Get the last seen diff rkey for this root (if any) - let after_rkey = root_uri.as_ref().and_then(|uri| { - last_seen_diffs - .get(uri) - .and_then(|diff_uri| diff_uri.rkey().map(|rk| rk.0.to_string())) - }); - - // Load state from this root (skipping already-seen diffs) - if let Some(pds_state) = - load_edit_state_from_root_id(fetcher, root_id, after_rkey.as_deref()).await? - { - // Import root snapshot into merged doc - if let Err(e) = merged_doc.import(&pds_state.root_snapshot) { - tracing::warn!("Failed to import root snapshot from {}: {:?}", root_did, e); - continue; - } - - // Import all diffs - for diff in &pds_state.diff_updates { - if let Err(e) = merged_doc.import(diff) { - tracing::warn!("Failed to import diff from {}: {:?}", root_did, e); - } - } - - // Update last seen diff for this root (for incremental sync next time) - if let (Some(uri), Some(last_diff)) = (&root_uri, &pds_state.last_diff_ref) { - updated_last_seen.insert(uri.clone(), last_diff.uri.clone().into_static()); - } - - // Track doc_ref from the first root we process (they should all match) - if merged_doc_ref.is_none() { - merged_doc_ref = Some(pds_state.doc_ref.clone()); - } - - // Track "our" root/diff refs for sync state (used when syncing back) - // We want to track our own edit.root so subsequent diffs go to the right place - let is_our_root = current_did.as_ref().is_some_and(|did| root_did == *did); - - if is_our_root { - // This is our own root - use it for sync state - our_root_ref = Some(pds_state.root_ref); - our_last_diff_ref = pds_state.last_diff_ref; - } else if our_root_ref.is_none() { - // We don't have our own root yet - use the first one we find - // (this handles the case where we're a new collaborator with no edit state) - our_root_ref = Some(pds_state.root_ref); - our_last_diff_ref = pds_state.last_diff_ref; - } - } - } - - // Export merged state as new snapshot - let merged_snapshot = merged_doc.export(loro::ExportMode::Snapshot).map_err(|e| { - WeaverError::InvalidNotebook(format!("Failed to export merged snapshot: {}", e)) - })?; - - tracing::debug!( - "load_all_edit_states_from_pds: merged document, snapshot size = {} bytes", - merged_snapshot.len() - ); - - // If we found any roots, return the merged state (includes updated last_seen map) - // Note: our_root_ref might be from another collaborator if we haven't created our own yet - Ok(our_root_ref.map(|root_ref| PdsEditState { - root_ref, - last_diff_ref: our_last_diff_ref, - root_snapshot: merged_snapshot.into(), - diff_updates: vec![], // Already merged into snapshot - last_seen_diffs: updated_last_seen, - doc_ref: merged_doc_ref.expect("Should have at least one doc_ref if we have a root"), - })) -} + let client = fetcher.get_client(); -/// Internal helper to load edit state given a root record ID. -/// -/// If `after_rkey` is provided, only diffs with rkey > after_rkey are fetched. -/// This enables incremental sync by skipping diffs we've already imported. -async fn load_edit_state_from_root_id( - fetcher: &Fetcher, - root_id: RecordId<'static>, - after_rkey: Option<&str>, -) -> Result, WeaverError> { - // Build root URI - let root_uri = AtUri::new(&format_smolstr!( - "at://{}/{}/{}", - root_id.did, - ROOT_NSID, - root_id.rkey.as_ref() - )) - .map_err(|e| WeaverError::InvalidNotebook(format!("Invalid root URI: {}", e)))? - .into_static(); - - // Fetch the root record using get_record helper - let root_response = fetcher - .client - .get_record::(&root_uri) + // Get collaborators for this resource. + let collaborators = client + .find_collaborators_for_resource(entry_uri) .await - .map_err(|e| WeaverError::InvalidNotebook(format!("Failed to fetch root: {}", e)))?; - - let root_output = root_response - .into_output() - .map_err(|e| WeaverError::InvalidNotebook(format!("Failed to parse root: {}", e)))?; - - let root_cid = root_output - .cid - .ok_or_else(|| WeaverError::InvalidNotebook("Root response missing CID".into()))?; - - let root_ref = StrongRef::new() - .uri(root_uri.clone()) - .cid(root_cid.into_static()) - .build(); + .unwrap_or_default(); - // Extract the DocRef from the root record - let doc_ref = root_output.value.doc.into_static(); + let current_did = fetcher.current_did().await; - // Fetch the root snapshot blob - let root_snapshot = fetch_blob( - fetcher, - &root_id.did, - root_output.value.snapshot.blob().cid(), + load_all_edit_states( + client.as_ref(), + entry_uri, + collaborators, + current_did.as_ref(), + last_seen_diffs, ) - .await?; - - // Find all diffs for this root - let diff_ids = find_diffs_for_root(fetcher, &root_uri).await?; - - if diff_ids.is_empty() { - return Ok(Some(PdsEditState { - root_ref, - last_diff_ref: None, - root_snapshot, - diff_updates: vec![], - last_seen_diffs: std::collections::HashMap::new(), - doc_ref, - })); - } - - // Fetch all diffs and store in BTreeMap keyed by rkey (TID) for sorted order - // TIDs are lexicographically sortable timestamps - let mut diffs_by_rkey: BTreeMap< - CowStr<'static>, - (Diff<'static>, Cid<'static>, AtUri<'static>), - > = BTreeMap::new(); - - for diff_id in &diff_ids { - let rkey_str: &str = diff_id.rkey.as_ref(); - - // Skip diffs we've already seen (rkey/TID is lexicographically sortable by time) - if let Some(after) = after_rkey { - if rkey_str <= after { - tracing::trace!("Skipping already-seen diff rkey: {}", rkey_str); - continue; - } - } - - let diff_uri = AtUri::new(&format_smolstr!( - "at://{}/{}/{}", - diff_id.did, - DIFF_NSID, - rkey_str - )) - .map_err(|e| WeaverError::InvalidNotebook(format!("Invalid diff URI: {}", e)))? - .into_static(); - - let diff_response = fetcher - .client - .get_record::(&diff_uri) - .await - .map_err(|e| WeaverError::InvalidNotebook(format!("Failed to fetch diff: {}", e)))?; - - let diff_output = diff_response - .into_output() - .map_err(|e| WeaverError::InvalidNotebook(format!("Failed to parse diff: {}", e)))?; - - let diff_cid = diff_output - .cid - .ok_or_else(|| WeaverError::InvalidNotebook("Diff response missing CID".into()))?; - - diffs_by_rkey.insert( - rkey_str.to_cowstr().into_static(), - ( - diff_output.value.into_static(), - diff_cid.into_static(), - diff_uri, - ), - ); - } - - // 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 { - // 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()); - } - - Ok(Some(PdsEditState { - root_ref, - last_diff_ref, - root_snapshot, - diff_updates, - last_seen_diffs: std::collections::HashMap::new(), - doc_ref, - })) + .await + .map_err(|e| WeaverError::InvalidNotebook(e.to_string())) } /// Load document state by merging local storage and PDS state. @@ -1365,13 +433,15 @@ pub async fn load_and_merge_document( // for drafts use single-repo loading (draft sharing requires knowing the URI) let pds_state = if let Some(uri) = entry_uri { // Published entry: load from ALL collaborators (multi-repo CRDT merge) - let empty_last_seen = std::collections::HashMap::new(); + let empty_last_seen = HashMap::new(); load_all_edit_states_from_pds(fetcher, uri, &empty_last_seen).await? } else if let Some(did) = fetcher.current_did().await { // Unpublished draft: single-repo for now // (draft sharing would require collaborator to know the draft URI) let draft_uri = build_draft_uri(&did, draft_key); - load_edit_state_from_draft(fetcher, &draft_uri).await? + load_edit_state_from_draft(fetcher.get_client().as_ref(), &draft_uri) + .await + .map_err(|e| WeaverError::InvalidNotebook(e.to_string()))? } else { // Not authenticated, can't query PDS None diff --git a/crates/weaver-common/src/agent.rs b/crates/weaver-common/src/agent.rs index 85d29d1..401f6ff 100644 --- a/crates/weaver-common/src/agent.rs +++ b/crates/weaver-common/src/agent.rs @@ -2589,6 +2589,35 @@ pub trait WeaverExt: AgentSessionExt + XrpcExt + Send + Sync + Sized { Ok(contributors.into_iter().collect()) } } + + /// Fetch a blob from any PDS by DID and CID. + /// + /// Resolves the DID to find its PDS, then fetches the blob. + fn fetch_blob<'a>( + &'a self, + did: &'a Did<'_>, + cid: &'a jacquard::types::string::Cid<'_>, + ) -> impl Future> + 'a { + async move { + use weaver_api::com_atproto::sync::get_blob::GetBlob; + + let pds_url = self.pds_for_did(did).await.map_err(|e| { + AgentError::from(ClientError::from(e).with_context("Failed to resolve PDS for DID")) + })?; + + let request = GetBlob::new().did(did.clone()).cid(cid.clone()).build(); + + let response = self + .xrpc(pds_url) + .send(&request) + .await + .map_err(|e| AgentError::from(ClientError::from(e)))?; + + let output = response.into_output().map_err(|e| AgentError::xrpc(e))?; + + Ok(output.body) + } + } } /// A version of a record from a collaborator's repository. diff --git a/crates/weaver-editor-crdt/Cargo.toml b/crates/weaver-editor-crdt/Cargo.toml new file mode 100644 index 0000000..3afb045 --- /dev/null +++ b/crates/weaver-editor-crdt/Cargo.toml @@ -0,0 +1,46 @@ +[package] +name = "weaver-editor-crdt" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "CRDT-backed editor with AT Protocol sync" + +[features] +default = [] +collab = ["weaver-common/iroh"] +weaver = ["dep:weaver-renderer"] # Entry-specific conveniences +use-index = ["weaver-common/use-index"] # Use weaver-index for reads + +[[bin]] +name = "editor_worker" +path = "src/bin/editor_worker.rs" + +[dependencies] +weaver-editor-core = { path = "../weaver-editor-core" } +weaver-common = { path = "../weaver-common", features = ["perf"] } +weaver-api = { path = "../weaver-api" } +jacquard = { workspace = true } +loro = "1.9" +serde = { workspace = true } +smol_str = "0.3" +tracing = { workspace = true } +thiserror = "2" +futures-util = "0.3" +n0-future = { workspace = true } + +# Optional weaver-specific deps +weaver-renderer = { path = "../weaver-renderer", optional = true } + +[target.'cfg(all(target_family = "wasm", target_os = "unknown"))'.dependencies] +gloo-worker = { version = "0.5", features = ["futures"] } +wasm-bindgen = "0.2" +wasm-bindgen-futures = "0.4" +base64 = "0.22" +tokio = { version = "1", features = ["sync"] } +console_error_panic_hook = "0.1" +tracing-wasm = "0.2" +tracing-subscriber = { version = "0.3", default-features = false, features = ["std", "registry", "env-filter"] } + +[target.'cfg(not(all(target_family = "wasm", target_os = "unknown")))'.dependencies] +base64 = "0.22" +tokio = { version = "1", features = ["sync"] } diff --git a/crates/weaver-editor-crdt/src/bin/editor_worker.rs b/crates/weaver-editor-crdt/src/bin/editor_worker.rs new file mode 100644 index 0000000..fba3980 --- /dev/null +++ b/crates/weaver-editor-crdt/src/bin/editor_worker.rs @@ -0,0 +1,45 @@ +//! Entry point for the editor web worker. +//! +//! This binary is compiled separately and loaded by the main app +//! to handle CPU-intensive editor operations off the main thread. + +#[cfg(all(target_family = "wasm", target_os = "unknown"))] +fn main() { + console_error_panic_hook::set_once(); + use tracing::Level; + use tracing::subscriber::set_global_default; + use tracing_subscriber::Registry; + use tracing_subscriber::filter::EnvFilter; + use tracing_subscriber::layer::SubscriberExt; + + let console_level = if cfg!(debug_assertions) { + Level::DEBUG + } else { + Level::DEBUG + }; + + let wasm_layer = tracing_wasm::WASMLayer::new( + tracing_wasm::WASMLayerConfigBuilder::new() + .set_max_level(console_level) + .build(), + ); + + // Filter out noisy crates + let filter = EnvFilter::new( + "debug,loro_internal=warn,jacquard_identity=info,jacquard_common=info,iroh=info", + ); + + let reg = Registry::default().with(filter).with(wasm_layer); + + let _ = set_global_default(reg); + + use gloo_worker::Registrable; + use weaver_editor_crdt::EditorReactor; + + EditorReactor::registrar().register(); +} + +#[cfg(not(all(target_family = "wasm", target_os = "unknown")))] +fn main() { + eprintln!("This binary is only meant to run as a WASM web worker"); +} diff --git a/crates/weaver-editor-crdt/src/buffer.rs b/crates/weaver-editor-crdt/src/buffer.rs new file mode 100644 index 0000000..7e7b47d --- /dev/null +++ b/crates/weaver-editor-crdt/src/buffer.rs @@ -0,0 +1,237 @@ +//! Loro-backed text buffer implementing core editor traits. + +use std::cell::RefCell; +use std::ops::Range; +use std::rc::Rc; + +use loro::{LoroDoc, LoroText, UndoManager as LoroUndoManager, VersionVector}; +use smol_str::{SmolStr, ToSmolStr}; +use weaver_editor_core::{TextBuffer, UndoManager}; + +use crate::CrdtError; + +/// Loro-backed text buffer with undo/redo support. +/// +/// Wraps a `LoroDoc` with a text container and provides implementations +/// of the `TextBuffer` and `UndoManager` traits from weaver-editor-core. +#[derive(Clone)] +pub struct LoroTextBuffer { + doc: LoroDoc, + content: LoroText, + undo_mgr: Rc>, +} + +impl LoroTextBuffer { + /// Create a new empty buffer. + pub fn new() -> Self { + let doc = LoroDoc::new(); + let content = doc.get_text("content"); + let undo_mgr = Rc::new(RefCell::new(LoroUndoManager::new(&doc))); + + Self { + doc, + content, + undo_mgr, + } + } + + /// Create a buffer from an existing Loro snapshot. + pub fn from_snapshot(snapshot: &[u8]) -> Result { + let doc = LoroDoc::new(); + doc.import(snapshot)?; + let content = doc.get_text("content"); + let undo_mgr = Rc::new(RefCell::new(LoroUndoManager::new(&doc))); + + Ok(Self { + doc, + content, + undo_mgr, + }) + } + + /// Get the underlying Loro document. + pub fn doc(&self) -> &LoroDoc { + &self.doc + } + + /// Get the text container. + pub fn content(&self) -> &LoroText { + &self.content + } + + /// Export full snapshot. + pub fn export_snapshot(&self) -> Vec { + self.doc + .export(loro::ExportMode::Snapshot) + .expect("snapshot export should not fail") + } + + /// Export updates since given version. + pub fn export_updates_since(&self, version: &VersionVector) -> Option> { + use std::borrow::Cow; + + let current_vv = self.doc.oplog_vv(); + + if *version == current_vv { + return None; + } + + let updates = self + .doc + .export(loro::ExportMode::Updates { + from: Cow::Owned(version.clone()), + }) + .ok()?; + + if updates.is_empty() { + return None; + } + + Some(updates) + } + + /// Import remote changes. + pub fn import(&mut self, data: &[u8]) -> Result<(), CrdtError> { + self.doc.import(data)?; + Ok(()) + } + + /// Get current version vector. + pub fn version(&self) -> VersionVector { + self.doc.oplog_vv() + } +} + +impl Default for LoroTextBuffer { + fn default() -> Self { + Self::new() + } +} + +impl TextBuffer for LoroTextBuffer { + fn len_bytes(&self) -> usize { + self.content.to_string().len() + } + + fn len_chars(&self) -> usize { + self.content.len_unicode() + } + + fn insert(&mut self, char_offset: usize, text: &str) { + self.content.insert(char_offset, text).ok(); + } + + fn delete(&mut self, char_range: Range) { + self.content.delete(char_range.start, char_range.len()).ok(); + } + + fn slice(&self, char_range: Range) -> Option { + let s = self.content.to_string(); + let chars: Vec = s.chars().collect(); + + if char_range.end > chars.len() { + return None; + } + + let slice: String = chars[char_range].iter().collect(); + Some(slice.to_smolstr()) + } + + fn char_at(&self, char_offset: usize) -> Option { + let s = self.content.to_string(); + s.chars().nth(char_offset) + } + + fn to_string(&self) -> String { + self.content.to_string() + } + + fn char_to_byte(&self, char_offset: usize) -> usize { + let s = self.content.to_string(); + s.char_indices() + .nth(char_offset) + .map(|(i, _)| i) + .unwrap_or(s.len()) + } + + fn byte_to_char(&self, byte_offset: usize) -> usize { + let s = self.content.to_string(); + s[..byte_offset.min(s.len())].chars().count() + } +} + +impl UndoManager for LoroTextBuffer { + fn can_undo(&self) -> bool { + self.undo_mgr.borrow().can_undo() + } + + fn can_redo(&self) -> bool { + self.undo_mgr.borrow().can_redo() + } + + fn undo(&mut self) -> bool { + self.undo_mgr.borrow_mut().undo().is_ok() + } + + fn redo(&mut self) -> bool { + self.undo_mgr.borrow_mut().redo().is_ok() + } + + fn clear_history(&mut self) { + // Loro's UndoManager doesn't have a clear method + // Create a new one to effectively clear history + self.undo_mgr = Rc::new(RefCell::new(LoroUndoManager::new(&self.doc))); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_basic_operations() { + let mut buffer = LoroTextBuffer::new(); + + buffer.insert(0, "Hello"); + assert_eq!(buffer.to_string(), "Hello"); + + buffer.insert(5, " World"); + assert_eq!(buffer.to_string(), "Hello World"); + + buffer.delete(5..6); + assert_eq!(buffer.to_string(), "HelloWorld"); + } + + #[test] + fn test_snapshot_roundtrip() { + let mut buffer = LoroTextBuffer::new(); + buffer.insert(0, "Test content"); + + let snapshot = buffer.export_snapshot(); + let restored = LoroTextBuffer::from_snapshot(&snapshot).unwrap(); + + assert_eq!(restored.to_string(), "Test content"); + } + + #[test] + fn test_slice() { + let mut buffer = LoroTextBuffer::new(); + buffer.insert(0, "Hello World"); + + assert_eq!(buffer.slice(0..5).as_deref(), Some("Hello")); + assert_eq!(buffer.slice(6..11).as_deref(), Some("World")); + assert_eq!(buffer.slice(0..100), None); + } + + #[test] + fn test_offset_conversion() { + let mut buffer = LoroTextBuffer::new(); + buffer.insert(0, "hello 🌍"); + + assert_eq!(buffer.len_chars(), 7); // h e l l o 🌍 + assert_eq!(buffer.len_bytes(), 10); // 6 + 4 + + assert_eq!(buffer.char_to_byte(6), 6); // before emoji + assert_eq!(buffer.char_to_byte(7), 10); // after emoji + } +} diff --git a/crates/weaver-editor-crdt/src/document.rs b/crates/weaver-editor-crdt/src/document.rs new file mode 100644 index 0000000..8dd4696 --- /dev/null +++ b/crates/weaver-editor-crdt/src/document.rs @@ -0,0 +1,159 @@ +//! CRDT document trait and sync state tracking. + +use loro::VersionVector; +use weaver_api::com_atproto::repo::strong_ref::StrongRef; + +/// Sync state for a CRDT document. +/// +/// Tracks the edit root, last diff, and version at last sync. +#[derive(Clone, Debug, Default)] +pub struct SyncState { + /// StrongRef to the sh.weaver.edit.root record. + pub edit_root: Option>, + + /// StrongRef to the most recent sh.weaver.edit.diff record. + pub last_diff: Option>, + + /// Version vector at the time of last sync. + pub last_synced_version: Option, +} + +impl SyncState { + /// Create new empty sync state. + pub fn new() -> Self { + Self::default() + } + + /// Check if we have an edit root (i.e., have synced at least once). + pub fn has_root(&self) -> bool { + self.edit_root.is_some() + } +} + +/// Trait for CRDT documents that can be synced to AT Protocol PDS. +/// +/// Implementors provide access to the underlying CRDT operations +/// and sync state tracking. +pub trait CrdtDocument { + /// Export full snapshot bytes. + fn export_snapshot(&self) -> Vec; + + /// Export updates since the last synced version. + /// Returns None if no changes since last sync. + fn export_updates_since_sync(&self) -> Option>; + + /// Import remote changes. + fn import(&mut self, data: &[u8]) -> Result<(), crate::CrdtError>; + + /// Get current version vector. + fn version(&self) -> VersionVector; + + /// Get the edit root StrongRef. + fn edit_root(&self) -> Option>; + + /// Set the edit root StrongRef. + fn set_edit_root(&mut self, root: Option>); + + /// Get the last diff StrongRef. + fn last_diff(&self) -> Option>; + + /// Set the last diff StrongRef. + fn set_last_diff(&mut self, diff: Option>); + + /// Mark current version as synced. + fn mark_synced(&mut self); + + /// Check if there are changes since last sync. + fn has_unsynced_changes(&self) -> bool; +} + +// Blanket implementation for LoroTextBuffer with embedded SyncState +// (Concrete types can provide their own implementations) + +/// A simple CRDT document wrapping LoroTextBuffer with sync state. +pub struct SimpleCrdtDocument { + buffer: crate::LoroTextBuffer, + sync_state: SyncState, +} + +impl SimpleCrdtDocument { + /// Create a new empty document. + pub fn new() -> Self { + Self { + buffer: crate::LoroTextBuffer::new(), + sync_state: SyncState::new(), + } + } + + /// Create from snapshot. + pub fn from_snapshot(snapshot: &[u8]) -> Result { + Ok(Self { + buffer: crate::LoroTextBuffer::from_snapshot(snapshot)?, + sync_state: SyncState::new(), + }) + } + + /// Get the underlying buffer. + pub fn buffer(&self) -> &crate::LoroTextBuffer { + &self.buffer + } + + /// Get mutable access to the buffer. + pub fn buffer_mut(&mut self) -> &mut crate::LoroTextBuffer { + &mut self.buffer + } +} + +impl Default for SimpleCrdtDocument { + fn default() -> Self { + Self::new() + } +} + +impl CrdtDocument for SimpleCrdtDocument { + fn export_snapshot(&self) -> Vec { + self.buffer.export_snapshot() + } + + fn export_updates_since_sync(&self) -> Option> { + self.sync_state + .last_synced_version + .as_ref() + .and_then(|v| self.buffer.export_updates_since(v)) + } + + fn import(&mut self, data: &[u8]) -> Result<(), crate::CrdtError> { + self.buffer.import(data) + } + + fn version(&self) -> VersionVector { + self.buffer.version() + } + + fn edit_root(&self) -> Option> { + self.sync_state.edit_root.clone() + } + + fn set_edit_root(&mut self, root: Option>) { + self.sync_state.edit_root = root; + } + + fn last_diff(&self) -> Option> { + self.sync_state.last_diff.clone() + } + + fn set_last_diff(&mut self, diff: Option>) { + self.sync_state.last_diff = diff; + } + + fn mark_synced(&mut self) { + self.sync_state.last_synced_version = Some(self.buffer.version()); + } + + fn has_unsynced_changes(&self) -> bool { + match &self.sync_state.last_synced_version { + None => true, // Never synced + Some(last) => self.buffer.version() != *last, + } + } +} diff --git a/crates/weaver-editor-crdt/src/error.rs b/crates/weaver-editor-crdt/src/error.rs new file mode 100644 index 0000000..2e4d594 --- /dev/null +++ b/crates/weaver-editor-crdt/src/error.rs @@ -0,0 +1,52 @@ +//! Error types for CRDT operations. + +use thiserror::Error; + +/// Errors that can occur during CRDT operations. +#[derive(Error, Debug)] +#[non_exhaustive] +pub enum CrdtError { + /// Failed to import CRDT data. + #[error("failed to import CRDT data: {0}")] + Import(String), + + /// Failed to export CRDT data. + #[error("failed to export CRDT data: {0}")] + Export(String), + + /// Sync operation failed. + #[error("sync failed: {0}")] + Sync(String), + + /// Not authenticated. + #[error("not authenticated")] + NotAuthenticated, + + /// Invalid AT-URI. + #[error("invalid AT-URI: {0}")] + InvalidUri(String), + + /// XRPC call failed. + #[error("XRPC error: {0}")] + Xrpc(String), + + /// Serialization error. + #[error("serialization error: {0}")] + Serialization(String), + + /// Loro CRDT error. + #[error("loro error: {0}")] + Loro(String), +} + +impl From for CrdtError { + fn from(e: loro::LoroError) -> Self { + CrdtError::Import(e.to_string()) + } +} + +impl From for CrdtError { + fn from(e: jacquard::client::AgentError) -> Self { + CrdtError::Xrpc(e.to_string()) + } +} diff --git a/crates/weaver-editor-crdt/src/lib.rs b/crates/weaver-editor-crdt/src/lib.rs new file mode 100644 index 0000000..3451f97 --- /dev/null +++ b/crates/weaver-editor-crdt/src/lib.rs @@ -0,0 +1,33 @@ +//! CRDT-backed editor with AT Protocol sync. +//! +//! This crate provides: +//! - `LoroTextBuffer`: Loro-backed text buffer implementing `TextBuffer` + `UndoManager` +//! - `CrdtDocument`: Trait for documents that can sync to AT Protocol PDS +//! - Generic sync logic for edit records (root/diff/draft) +//! - Worker implementation for off-main-thread CRDT operations + +mod buffer; +mod document; +mod error; +mod sync; + +pub mod worker; + +pub use buffer::LoroTextBuffer; +pub use document::{CrdtDocument, SimpleCrdtDocument, SyncState}; +pub use error::CrdtError; +pub use sync::{ + CreateRootResult, PdsEditState, RemoteDraft, SyncResult, + build_draft_uri, create_diff, create_edit_root, + find_all_edit_roots, find_diffs_for_root, find_edit_root_for_draft, + list_drafts, load_all_edit_states, load_edit_state_from_draft, + load_edit_state_from_entry, sync_to_pds, +}; + +// Re-export worker types +pub use worker::{WorkerInput, WorkerOutput}; +#[cfg(all(target_family = "wasm", target_os = "unknown"))] +pub use worker::EditorReactor; + +// Re-export Loro types that consumers need +pub use loro::{ExportMode, LoroDoc, LoroText, VersionVector}; diff --git a/crates/weaver-editor-crdt/src/sync.rs b/crates/weaver-editor-crdt/src/sync.rs new file mode 100644 index 0000000..f9593f8 --- /dev/null +++ b/crates/weaver-editor-crdt/src/sync.rs @@ -0,0 +1,963 @@ +//! PDS synchronization for CRDT documents. +//! +//! Generic sync logic for AT Protocol edit records (root/diff/draft). +//! Works with any client implementing the required traits. + +use std::collections::{BTreeMap, HashMap}; + +use jacquard::bytes::Bytes; +use jacquard::cowstr::ToCowStr; +use jacquard::prelude::*; +use jacquard::smol_str::format_smolstr; +use jacquard::types::blob::MimeType; +use jacquard::types::ident::AtIdentifier; +use jacquard::types::recordkey::RecordKey; +use jacquard::types::string::{AtUri, Cid, Did, Nsid}; +use jacquard::types::tid::Ticker; +use jacquard::types::uri::Uri; +use jacquard::url::Url; +use jacquard::{CowStr, IntoStatic, to_data}; +use loro::{ExportMode, LoroDoc}; +use weaver_api::com_atproto::repo::create_record::CreateRecord; +use weaver_api::com_atproto::repo::strong_ref::StrongRef; +use weaver_api::sh_weaver::edit::diff::Diff; +use weaver_api::sh_weaver::edit::draft::Draft; +use weaver_api::sh_weaver::edit::root::Root; +use weaver_api::sh_weaver::edit::{DocRef, DocRefValue, DraftRef, EntryRef}; +use weaver_common::agent::WeaverExt; +use weaver_common::constellation::{GetBacklinksQuery, RecordId}; + +use crate::CrdtError; +use crate::document::CrdtDocument; + +const ROOT_NSID: &str = "sh.weaver.edit.root"; +const DIFF_NSID: &str = "sh.weaver.edit.diff"; +const DRAFT_NSID: &str = "sh.weaver.edit.draft"; +const CONSTELLATION_URL: &str = "https://constellation.microcosm.blue"; + +/// Result of a sync operation. +#[derive(Clone, Debug)] +pub enum SyncResult { + /// Created a new root record (first sync). + CreatedRoot { + uri: AtUri<'static>, + cid: Cid<'static>, + }, + /// Created a new diff record. + CreatedDiff { + uri: AtUri<'static>, + cid: Cid<'static>, + }, + /// No changes to sync. + NoChanges, +} + +/// Result of creating an edit root. +pub struct CreateRootResult { + /// The root record URI. + pub root_uri: AtUri<'static>, + /// The root record CID. + pub root_cid: Cid<'static>, + /// Draft stub StrongRef if this was a new draft. + pub draft_ref: Option>, +} + +/// Build a DocRef for either a published entry or an unpublished draft. +fn build_doc_ref( + did: &Did<'_>, + draft_key: &str, + entry_uri: Option<&AtUri<'_>>, + entry_cid: Option<&Cid<'_>>, +) -> DocRef<'static> { + match (entry_uri, entry_cid) { + (Some(uri), Some(cid)) => DocRef { + value: DocRefValue::EntryRef(Box::new(EntryRef { + entry: StrongRef::new() + .uri(uri.clone().into_static()) + .cid(cid.clone().into_static()) + .build(), + extra_data: None, + })), + extra_data: None, + }, + _ => { + // Transform localStorage key to synthetic AT-URI + let rkey = extract_draft_rkey(draft_key); + let canonical_uri = format_smolstr!("at://{}/{}/{}", did, DRAFT_NSID, rkey); + + DocRef { + value: DocRefValue::DraftRef(Box::new(DraftRef { + draft_key: canonical_uri.into(), + extra_data: None, + })), + extra_data: None, + } + } + } +} + +/// Extract the rkey (TID) from a draft key. +fn extract_draft_rkey(draft_key: &str) -> String { + if let Some(tid) = draft_key.strip_prefix("new:") { + tid.to_string() + } else if draft_key.starts_with("at://") { + draft_key.split('/').last().unwrap_or(draft_key).to_string() + } else { + draft_key.to_string() + } +} + +/// Get current DID from session. +async fn get_current_did(client: &C) -> Result, CrdtError> +where + C: AgentSession, +{ + client + .session_info() + .await + .map(|(did, _)| did) + .ok_or(CrdtError::NotAuthenticated) +} + +/// Create the draft stub record on PDS. +async fn create_draft_stub( + client: &C, + did: &Did<'_>, + rkey: &str, +) -> Result<(AtUri<'static>, Cid<'static>), CrdtError> +where + C: XrpcClient + AgentSession, +{ + let draft = Draft::new() + .created_at(jacquard::types::datetime::Datetime::now()) + .build(); + + let draft_data = + to_data(&draft).map_err(|e| CrdtError::Serialization(format!("draft: {}", e)))?; + + let record_key = + RecordKey::any(rkey).map_err(|e| CrdtError::InvalidUri(format!("rkey: {}", e)))?; + + let collection = + Nsid::new(DRAFT_NSID).map_err(|e| CrdtError::InvalidUri(format!("nsid: {}", e)))?; + + let request = CreateRecord::new() + .repo(AtIdentifier::Did(did.clone().into_static())) + .collection(collection) + .rkey(record_key) + .record(draft_data) + .build(); + + let response = client + .send(request) + .await + .map_err(|e| CrdtError::Xrpc(e.to_string()))?; + + let output = response + .into_output() + .map_err(|e| CrdtError::Xrpc(e.to_string()))?; + + Ok((output.uri.into_static(), output.cid.into_static())) +} + +/// Create the edit root record for a document. +pub async fn create_edit_root( + client: &C, + doc: &D, + draft_key: &str, + entry_uri: Option<&AtUri<'_>>, + entry_cid: Option<&Cid<'_>>, +) -> Result +where + C: XrpcClient + IdentityResolver + AgentSession, + D: CrdtDocument, +{ + let did = get_current_did(client).await?; + + // For drafts, create the stub record first + let draft_ref: Option> = if entry_uri.is_none() { + let rkey = extract_draft_rkey(draft_key); + match create_draft_stub(client, &did, &rkey).await { + Ok((uri, cid)) => Some(StrongRef::new().uri(uri).cid(cid).build()), + Err(e) => { + let err_str = e.to_string(); + if err_str.contains("RecordAlreadyExists") || err_str.contains("already exists") { + // Draft exists, try to fetch it + let draft_uri_str = format!("at://{}/{}/{}", did, DRAFT_NSID, rkey); + if let Ok(draft_uri) = AtUri::new(&draft_uri_str) { + if let Ok(response) = client.get_record::(&draft_uri).await { + if let Ok(output) = response.into_output() { + output.cid.map(|cid| { + StrongRef::new() + .uri(draft_uri.into_static()) + .cid(cid.into_static()) + .build() + }) + } else { + None + } + } else { + None + } + } else { + None + } + } else { + tracing::warn!("Failed to create draft stub: {}", e); + None + } + } + } + } else { + None + }; + + // Export full snapshot + let snapshot = doc.export_snapshot(); + + // Upload snapshot blob + let mime_type = MimeType::new_static("application/octet-stream"); + let blob_ref = client + .upload_blob(snapshot, mime_type) + .await + .map_err(|e| CrdtError::Xrpc(format!("upload blob: {}", e)))?; + + // Build DocRef + let doc_ref = build_doc_ref(&did, draft_key, entry_uri, entry_cid); + + // Build root record + let root = Root::new().doc(doc_ref).snapshot(blob_ref).build(); + + let root_data = to_data(&root).map_err(|e| CrdtError::Serialization(format!("root: {}", e)))?; + + // Generate TID for the root rkey + let root_tid = Ticker::new().next(None); + let rkey = RecordKey::any(root_tid.as_str()) + .map_err(|e| CrdtError::InvalidUri(format!("rkey: {}", e)))?; + + let collection = + Nsid::new(ROOT_NSID).map_err(|e| CrdtError::InvalidUri(format!("nsid: {}", e)))?; + + let request = CreateRecord::new() + .repo(AtIdentifier::Did(did)) + .collection(collection) + .rkey(rkey) + .record(root_data) + .build(); + + let response = client + .send(request) + .await + .map_err(|e| CrdtError::Xrpc(e.to_string()))?; + + let output = response + .into_output() + .map_err(|e| CrdtError::Xrpc(e.to_string()))?; + + Ok(CreateRootResult { + root_uri: output.uri.into_static(), + root_cid: output.cid.into_static(), + draft_ref, + }) +} + +/// Create a diff record with updates since the last sync. +pub async fn create_diff( + client: &C, + doc: &D, + root_uri: &AtUri<'_>, + root_cid: &Cid<'_>, + prev_diff: Option<(&AtUri<'_>, &Cid<'_>)>, + draft_key: &str, + entry_uri: Option<&AtUri<'_>>, + entry_cid: Option<&Cid<'_>>, +) -> Result, Cid<'static>)>, CrdtError> +where + C: XrpcClient + IdentityResolver + AgentSession, + D: CrdtDocument, +{ + // Export updates since last sync + let updates = match doc.export_updates_since_sync() { + Some(u) => u, + None => return Ok(None), + }; + + let did = get_current_did(client).await?; + + // Threshold for inline vs blob storage (8KB max for inline per lexicon) + const INLINE_THRESHOLD: usize = 8192; + + let (blob_ref, inline_diff): (Option>, _) = + if updates.len() <= INLINE_THRESHOLD { + (None, Some(jacquard::bytes::Bytes::from(updates))) + } else { + let mime_type = MimeType::new_static("application/octet-stream"); + let blob = client + .upload_blob(updates, mime_type) + .await + .map_err(|e| CrdtError::Xrpc(format!("upload diff: {}", e)))?; + (Some(blob.into()), None) + }; + + // Build DocRef + let doc_ref = build_doc_ref(&did, draft_key, entry_uri, entry_cid); + + // Build root reference + let root_ref = StrongRef::new() + .uri(root_uri.clone().into_static()) + .cid(root_cid.clone().into_static()) + .build(); + + // Build prev reference + let prev_ref = prev_diff.map(|(uri, cid)| { + StrongRef::new() + .uri(uri.clone().into_static()) + .cid(cid.clone().into_static()) + .build() + }); + + // Build diff record + let diff = Diff::new() + .doc(doc_ref) + .root(root_ref) + .maybe_snapshot(blob_ref) + .maybe_inline_diff(inline_diff) + .maybe_prev(prev_ref) + .build(); + + let diff_data = to_data(&diff).map_err(|e| CrdtError::Serialization(format!("diff: {}", e)))?; + + // Generate TID for the diff rkey + let diff_tid = Ticker::new().next(None); + let rkey = RecordKey::any(diff_tid.as_str()) + .map_err(|e| CrdtError::InvalidUri(format!("rkey: {}", e)))?; + + let collection = + Nsid::new(DIFF_NSID).map_err(|e| CrdtError::InvalidUri(format!("nsid: {}", e)))?; + + let request = CreateRecord::new() + .repo(AtIdentifier::Did(did)) + .collection(collection) + .rkey(rkey) + .record(diff_data) + .build(); + + let response = client + .send(request) + .await + .map_err(|e| CrdtError::Xrpc(e.to_string()))?; + + let output = response + .into_output() + .map_err(|e| CrdtError::Xrpc(e.to_string()))?; + + Ok(Some((output.uri.into_static(), output.cid.into_static()))) +} + +/// Sync the document to the PDS. +pub async fn sync_to_pds( + client: &C, + doc: &mut D, + draft_key: &str, + entry_uri: Option<&AtUri<'_>>, + entry_cid: Option<&Cid<'_>>, +) -> Result +where + C: XrpcClient + IdentityResolver + AgentSession, + D: CrdtDocument, +{ + if !doc.has_unsynced_changes() { + return Ok(SyncResult::NoChanges); + } + + if doc.edit_root().is_none() { + // First sync - create root + let result = create_edit_root(client, doc, draft_key, entry_uri, entry_cid).await?; + + let root_ref = StrongRef::new() + .uri(result.root_uri.clone()) + .cid(result.root_cid.clone()) + .build(); + + doc.set_edit_root(Some(root_ref)); + doc.set_last_diff(None); + doc.mark_synced(); + + Ok(SyncResult::CreatedRoot { + uri: result.root_uri, + cid: result.root_cid, + }) + } else { + // Subsequent sync - create diff + let root = doc.edit_root().unwrap(); + let prev = doc.last_diff(); + + let prev_refs = prev.as_ref().map(|p| (&p.uri, &p.cid)); + + let result = create_diff( + client, doc, &root.uri, &root.cid, prev_refs, draft_key, entry_uri, entry_cid, + ) + .await?; + + match result { + Some((uri, cid)) => { + let diff_ref = StrongRef::new().uri(uri.clone()).cid(cid.clone()).build(); + doc.set_last_diff(Some(diff_ref)); + doc.mark_synced(); + + Ok(SyncResult::CreatedDiff { uri, cid }) + } + None => Ok(SyncResult::NoChanges), + } + } +} + +/// Find all edit roots for an entry using weaver-index. +#[cfg(feature = "use-index")] +pub async fn find_all_edit_roots( + client: &C, + entry_uri: &AtUri<'_>, + _collaborator_dids: Vec>, +) -> Result>, CrdtError> +where + C: WeaverExt, +{ + use jacquard::types::ident::AtIdentifier; + use jacquard::types::nsid::Nsid; + use weaver_api::sh_weaver::edit::get_edit_history::GetEditHistory; + + let response = client + .send(GetEditHistory::new().resource(entry_uri.clone()).build()) + .await + .map_err(|e| CrdtError::Xrpc(format!("get edit history: {}", e)))?; + + let output = response + .into_output() + .map_err(|e| CrdtError::Xrpc(format!("parse edit history: {}", e)))?; + + let roots: Vec> = output + .roots + .into_iter() + .filter_map(|entry| { + let uri = AtUri::new(entry.uri.as_ref()).ok()?; + let did = match uri.authority() { + AtIdentifier::Did(d) => d.clone().into_static(), + _ => return None, + }; + let rkey = uri.rkey()?.clone().into_static(); + Some(RecordId { + did, + collection: Nsid::raw(ROOT_NSID).into_static(), + rkey, + }) + }) + .collect(); + + tracing::debug!("find_all_edit_roots (index): found {} roots", roots.len()); + + Ok(roots) +} + +/// Find all edit roots for an entry using Constellation backlinks. +#[cfg(not(feature = "use-index"))] +pub async fn find_all_edit_roots( + client: &C, + entry_uri: &AtUri<'_>, + collaborator_dids: Vec>, +) -> Result>, CrdtError> +where + C: XrpcClient, +{ + let constellation_url = + Url::parse(CONSTELLATION_URL).map_err(|e| CrdtError::InvalidUri(e.to_string()))?; + + let query = GetBacklinksQuery { + subject: Uri::At(entry_uri.clone().into_static()), + source: format_smolstr!("{}:doc.value.entry.uri", ROOT_NSID).into(), + cursor: None, + did: collaborator_dids, + limit: 100, + }; + + let response = client + .xrpc(constellation_url) + .send(&query) + .await + .map_err(|e| CrdtError::Xrpc(e.to_string()))?; + + let output = response + .into_output() + .map_err(|e| CrdtError::Xrpc(e.to_string()))?; + + Ok(output + .records + .into_iter() + .map(|r| r.into_static()) + .collect()) +} + +/// Find all diffs for a root record using Constellation backlinks. +pub async fn find_diffs_for_root( + client: &C, + root_uri: &AtUri<'_>, +) -> Result>, CrdtError> +where + C: XrpcClient, +{ + let constellation_url = + Url::parse(CONSTELLATION_URL).map_err(|e| CrdtError::InvalidUri(e.to_string()))?; + + let mut all_diffs = Vec::new(); + let mut cursor: Option = None; + + loop { + let query = GetBacklinksQuery { + subject: Uri::At(root_uri.clone().into_static()), + source: format_smolstr!("{}:root.uri", DIFF_NSID).into(), + cursor: cursor.map(Into::into), + did: vec![], + limit: 100, + }; + + let response = client + .xrpc(constellation_url.clone()) + .send(&query) + .await + .map_err(|e| CrdtError::Xrpc(e.to_string()))?; + + let output = response + .into_output() + .map_err(|e| CrdtError::Xrpc(e.to_string()))?; + + all_diffs.extend(output.records.into_iter().map(|r| r.into_static())); + + match output.cursor { + Some(c) => cursor = Some(c.to_string()), + None => break, + } + } + + Ok(all_diffs) +} + +// ============================================================================ +// Loading functions +// ============================================================================ + +/// Result of loading edit state from PDS. +#[derive(Clone, Debug)] +pub struct PdsEditState { + /// The root record reference. + pub root_ref: StrongRef<'static>, + /// The latest diff reference (if any diffs exist). + pub last_diff_ref: Option>, + /// The Loro snapshot bytes from the root. + pub root_snapshot: Bytes, + /// All diff update bytes in order (oldest first, by TID). + pub diff_updates: Vec, + /// Last seen diff URI per collaborator root (for incremental sync). + pub last_seen_diffs: HashMap, AtUri<'static>>, + /// The DocRef from the root record. + pub doc_ref: DocRef<'static>, +} + +/// Find edit root for a draft using Constellation backlinks. +pub async fn find_edit_root_for_draft( + client: &C, + draft_uri: &AtUri<'_>, +) -> Result>, CrdtError> +where + C: XrpcClient, +{ + let constellation_url = + Url::parse(CONSTELLATION_URL).map_err(|e| CrdtError::InvalidUri(e.to_string()))?; + + let query = GetBacklinksQuery { + subject: Uri::At(draft_uri.clone().into_static()), + source: format_smolstr!("{}:doc.value.draft_key", ROOT_NSID).into(), + cursor: None, + did: vec![], + limit: 1, + }; + + let response = client + .xrpc(constellation_url) + .send(&query) + .await + .map_err(|e| CrdtError::Xrpc(format!("constellation query: {}", e)))?; + + let output = response + .into_output() + .map_err(|e| CrdtError::Xrpc(format!("parse constellation: {}", e)))?; + + Ok(output.records.into_iter().next().map(|r| r.into_static())) +} + +/// Build a canonical draft URI from draft key and DID. +pub fn build_draft_uri(did: &Did<'_>, draft_key: &str) -> AtUri<'static> { + let rkey = extract_draft_rkey(draft_key); + let uri_str = format_smolstr!("at://{}/{}/{}", did, DRAFT_NSID, rkey); + AtUri::new(&uri_str).unwrap().into_static() +} + +/// Load edit state from a root record ID. +async fn load_edit_state_from_root_id( + client: &C, + root_id: RecordId<'static>, + after_rkey: Option<&str>, +) -> Result, CrdtError> +where + C: WeaverExt, +{ + let root_uri = AtUri::new(&format_smolstr!( + "at://{}/{}/{}", + root_id.did, + ROOT_NSID, + root_id.rkey.as_ref() + )) + .map_err(|e| CrdtError::InvalidUri(format!("root URI: {}", e)))? + .into_static(); + + let root_response = client + .get_record::(&root_uri) + .await + .map_err(|e| CrdtError::Xrpc(format!("fetch root: {}", e)))?; + + let root_output = root_response + .into_output() + .map_err(|e| CrdtError::Xrpc(format!("parse root: {}", e)))?; + + let root_cid = root_output + .cid + .ok_or_else(|| CrdtError::Xrpc("root missing CID".into()))?; + + let root_ref = StrongRef::new() + .uri(root_uri.clone()) + .cid(root_cid.into_static()) + .build(); + + let doc_ref = root_output.value.doc.into_static(); + + let root_snapshot = client + .fetch_blob(&root_id.did, root_output.value.snapshot.blob().cid()) + .await + .map_err(|e| CrdtError::Xrpc(format!("fetch snapshot blob: {}", e)))?; + + let diff_ids = find_diffs_for_root(client, &root_uri).await?; + + if diff_ids.is_empty() { + return Ok(Some(PdsEditState { + root_ref, + last_diff_ref: None, + root_snapshot, + diff_updates: vec![], + last_seen_diffs: HashMap::new(), + doc_ref, + })); + } + + let mut diffs_by_rkey: BTreeMap< + CowStr<'static>, + (Diff<'static>, Cid<'static>, AtUri<'static>), + > = BTreeMap::new(); + + for diff_id in &diff_ids { + let rkey_str: &str = diff_id.rkey.as_ref(); + + if let Some(after) = after_rkey { + if rkey_str <= after { + continue; + } + } + + let diff_uri = AtUri::new(&format_smolstr!( + "at://{}/{}/{}", + diff_id.did, + DIFF_NSID, + rkey_str + )) + .map_err(|e| CrdtError::InvalidUri(format!("diff URI: {}", e)))? + .into_static(); + + let diff_response = client + .get_record::(&diff_uri) + .await + .map_err(|e| CrdtError::Xrpc(format!("fetch diff: {}", e)))?; + + let diff_output = diff_response + .into_output() + .map_err(|e| CrdtError::Xrpc(format!("parse diff: {}", e)))?; + + let diff_cid = diff_output + .cid + .ok_or_else(|| CrdtError::Xrpc("diff missing CID".into()))?; + + diffs_by_rkey.insert( + rkey_str.to_cowstr().into_static(), + ( + diff_output.value.into_static(), + diff_cid.into_static(), + diff_uri, + ), + ); + } + + let mut diff_updates = Vec::new(); + let mut last_diff_ref = None; + + for (_rkey, (diff, cid, uri)) in &diffs_by_rkey { + let diff_bytes = if let Some(ref inline) = diff.inline_diff { + inline.clone() + } else if let Some(ref snapshot) = diff.snapshot { + client + .fetch_blob(&root_id.did, snapshot.blob().cid()) + .await + .map_err(|e| CrdtError::Xrpc(format!("fetch diff blob: {}", e)))? + } else { + tracing::warn!("Diff has neither inline_diff nor snapshot, skipping"); + continue; + }; + + diff_updates.push(diff_bytes); + last_diff_ref = Some(StrongRef::new().uri(uri.clone()).cid(cid.clone()).build()); + } + + Ok(Some(PdsEditState { + root_ref, + last_diff_ref, + root_snapshot, + diff_updates, + last_seen_diffs: HashMap::new(), + doc_ref, + })) +} + +/// Load edit state from PDS for an entry (single root). +pub async fn load_edit_state_from_entry( + client: &C, + entry_uri: &AtUri<'_>, + collaborator_dids: Vec>, +) -> Result, CrdtError> +where + C: WeaverExt, +{ + let root_id = match find_all_edit_roots(client, entry_uri, collaborator_dids) + .await? + .into_iter() + .next() + { + Some(id) => id, + None => return Ok(None), + }; + + load_edit_state_from_root_id(client, root_id, None).await +} + +/// Load edit state from PDS for a draft. +pub async fn load_edit_state_from_draft( + client: &C, + draft_uri: &AtUri<'_>, +) -> Result, CrdtError> +where + C: WeaverExt, +{ + let root_id = match find_edit_root_for_draft(client, draft_uri).await? { + Some(id) => id, + None => return Ok(None), + }; + + load_edit_state_from_root_id(client, root_id, None).await +} + +/// Load and merge edit states from ALL collaborator repos. +pub async fn load_all_edit_states( + client: &C, + entry_uri: &AtUri<'_>, + collaborator_dids: Vec>, + current_did: Option<&Did<'_>>, + last_seen_diffs: &HashMap, AtUri<'static>>, +) -> Result, CrdtError> +where + C: WeaverExt, +{ + let all_roots = find_all_edit_roots(client, entry_uri, collaborator_dids).await?; + + if all_roots.is_empty() { + return Ok(None); + } + + let merged_doc = LoroDoc::new(); + let mut our_root_ref: Option> = None; + let mut our_last_diff_ref: Option> = None; + let mut merged_doc_ref: Option> = None; + let mut updated_last_seen = last_seen_diffs.clone(); + + for root_id in all_roots { + let root_did = root_id.did.clone(); + + let root_uri = AtUri::new(&format_smolstr!( + "at://{}/{}/{}", + root_id.did, + ROOT_NSID, + root_id.rkey.as_ref() + )) + .ok() + .map(|u| u.into_static()); + + let after_rkey = root_uri.as_ref().and_then(|uri| { + last_seen_diffs + .get(uri) + .and_then(|diff_uri| diff_uri.rkey().map(|rk| rk.0.to_string())) + }); + + if let Some(pds_state) = + load_edit_state_from_root_id(client, root_id, after_rkey.as_deref()).await? + { + if let Err(e) = merged_doc.import(&pds_state.root_snapshot) { + tracing::warn!("Failed to import root snapshot from {}: {:?}", root_did, e); + continue; + } + + for diff in &pds_state.diff_updates { + if let Err(e) = merged_doc.import(diff) { + tracing::warn!("Failed to import diff from {}: {:?}", root_did, e); + } + } + + if let (Some(uri), Some(last_diff)) = (&root_uri, &pds_state.last_diff_ref) { + updated_last_seen.insert(uri.clone(), last_diff.uri.clone().into_static()); + } + + if merged_doc_ref.is_none() { + merged_doc_ref = Some(pds_state.doc_ref.clone()); + } + + let is_our_root = current_did.is_some_and(|did| root_did == *did); + + if is_our_root { + our_root_ref = Some(pds_state.root_ref); + our_last_diff_ref = pds_state.last_diff_ref; + } else if our_root_ref.is_none() { + our_root_ref = Some(pds_state.root_ref); + our_last_diff_ref = pds_state.last_diff_ref; + } + } + } + + let merged_snapshot = merged_doc + .export(ExportMode::Snapshot) + .map_err(|e| CrdtError::Loro(format!("export merged: {}", e)))?; + + Ok(our_root_ref.map(|root_ref| PdsEditState { + root_ref, + last_diff_ref: our_last_diff_ref, + root_snapshot: merged_snapshot.into(), + diff_updates: vec![], + last_seen_diffs: updated_last_seen, + doc_ref: merged_doc_ref.expect("Should have doc_ref if we have root"), + })) +} + +/// Remote draft info from PDS. +#[derive(Clone, Debug)] +pub struct RemoteDraft { + /// The draft record URI. + pub uri: AtUri<'static>, + /// The rkey (TID) of the draft. + pub rkey: String, + /// When the draft was created. + pub created_at: String, +} + +/// List all drafts for a user using weaver-index. +#[cfg(feature = "use-index")] +pub async fn list_drafts(client: &C, did: &Did<'_>) -> Result, CrdtError> +where + C: WeaverExt, +{ + use jacquard::types::ident::AtIdentifier; + use weaver_api::sh_weaver::edit::list_drafts::ListDrafts; + + let actor = AtIdentifier::Did(did.clone().into_static()); + let response = client + .send(ListDrafts::new().actor(actor).build()) + .await + .map_err(|e| CrdtError::Xrpc(format!("list drafts: {}", e)))?; + + let output = response + .into_output() + .map_err(|e| CrdtError::Xrpc(format!("parse list drafts: {}", e)))?; + + tracing::debug!("list_drafts (index): found {} drafts", output.drafts.len()); + + let drafts = output + .drafts + .into_iter() + .filter_map(|draft| { + let uri = AtUri::new(draft.uri.as_ref()).ok()?.into_static(); + let rkey = uri.rkey()?.0.as_str().to_string(); + let created_at = draft.created_at.to_string(); + Some(RemoteDraft { + uri, + rkey, + created_at, + }) + }) + .collect(); + + Ok(drafts) +} + +/// List all drafts for a user (direct PDS query, no index). +#[cfg(not(feature = "use-index"))] +pub async fn list_drafts(client: &C, did: &Did<'_>) -> Result, CrdtError> +where + C: WeaverExt, +{ + use weaver_api::com_atproto::repo::list_records::ListRecords; + + let pds_url = client + .pds_for_did(did) + .await + .map_err(|e| CrdtError::Xrpc(format!("resolve DID: {}", e)))?; + + let collection = + Nsid::new(DRAFT_NSID).map_err(|e| CrdtError::InvalidUri(format!("nsid: {}", e)))?; + + let request = ListRecords::new() + .repo(did.clone()) + .collection(collection) + .limit(100) + .build(); + + let response = client + .xrpc(pds_url) + .send(&request) + .await + .map_err(|e| CrdtError::Xrpc(format!("list records: {}", e)))?; + + let output = response + .into_output() + .map_err(|e| CrdtError::Xrpc(format!("parse list records: {}", e)))?; + + let mut drafts = Vec::new(); + for record in output.records { + let rkey = record + .uri + .rkey() + .map(|r| r.0.as_str().to_string()) + .unwrap_or_default(); + + let created_at = jacquard::from_data::(&record.value) + .map(|d| d.created_at.to_string()) + .unwrap_or_default(); + + drafts.push(RemoteDraft { + uri: record.uri.into_static(), + rkey, + created_at, + }); + } + + Ok(drafts) +} diff --git a/crates/weaver-editor-crdt/src/worker/mod.rs b/crates/weaver-editor-crdt/src/worker/mod.rs new file mode 100644 index 0000000..ca7ebbd --- /dev/null +++ b/crates/weaver-editor-crdt/src/worker/mod.rs @@ -0,0 +1,11 @@ +//! Worker implementation for off-main-thread CRDT operations. +//! +//! Currently WASM-specific using gloo-worker, but the core state machine +//! could be abstracted to work with any async channel pair. + +mod reactor; + +pub use reactor::{WorkerInput, WorkerOutput}; + +#[cfg(all(target_family = "wasm", target_os = "unknown"))] +pub use reactor::EditorReactor; diff --git a/crates/weaver-app/src/components/editor/worker.rs b/crates/weaver-editor-crdt/src/worker/reactor.rs similarity index 89% rename from crates/weaver-app/src/components/editor/worker.rs rename to crates/weaver-editor-crdt/src/worker/reactor.rs index 537cc16..a506a21 100644 --- a/crates/weaver-app/src/components/editor/worker.rs +++ b/crates/weaver-editor-crdt/src/worker/reactor.rs @@ -1,10 +1,10 @@ -//! Web Worker for offloading expensive editor operations. +//! Web Worker reactor for offloading expensive editor operations. //! //! This worker maintains a shadow copy of the Loro document and handles //! CPU-intensive operations like snapshot export and base64 encoding //! off the main thread. //! -//! When the `collab-worker` feature is enabled, also handles iroh P2P +//! When the `collab` feature is enabled, also handles iroh P2P //! networking for real-time collaboration. #[cfg(all(target_family = "wasm", target_os = "unknown"))] @@ -138,18 +138,18 @@ mod worker_impl { use gloo_worker::reactor::{ReactorScope, reactor}; use weaver_common::transport::CollaboratorInfo; - #[cfg(feature = "collab-worker")] + #[cfg(feature = "collab")] use jacquard::smol_str::ToSmolStr; - #[cfg(feature = "collab-worker")] + #[cfg(feature = "collab")] use std::sync::Arc; - #[cfg(feature = "collab-worker")] + #[cfg(feature = "collab")] use weaver_common::transport::{ CollabMessage, CollabNode, CollabSession, PresenceTracker, SessionEvent, TopicId, parse_node_id, }; /// Internal event from gossip handler task to main reactor loop. - #[cfg(feature = "collab-worker")] + #[cfg(feature = "collab")] enum CollabEvent { RemoteUpdates { data: Vec }, PresenceChanged(PresenceSnapshot), @@ -162,18 +162,18 @@ mod worker_impl { let mut doc: Option = None; let mut draft_key = SmolStr::default(); - // Collab state (only used when collab-worker feature enabled) - #[cfg(feature = "collab-worker")] + // Collab state (only used when collab feature enabled) + #[cfg(feature = "collab")] let mut collab_node: Option> = None; - #[cfg(feature = "collab-worker")] + #[cfg(feature = "collab")] let mut collab_session: Option> = None; - #[cfg(feature = "collab-worker")] + #[cfg(feature = "collab")] let mut collab_event_rx: Option> = None; - #[cfg(feature = "collab-worker")] + #[cfg(feature = "collab")] const OUR_COLOR: u32 = 0x4ECDC4FF; // Helper enum for racing coordinator messages vs collab events - #[cfg(feature = "collab-worker")] + #[cfg(feature = "collab")] enum RaceResult { CoordinatorMsg(Option), CollabEvent(Option), @@ -181,7 +181,7 @@ mod worker_impl { loop { // Race between coordinator messages and collab events - #[cfg(feature = "collab-worker")] + #[cfg(feature = "collab")] let race_result = if let Some(ref mut event_rx) = collab_event_rx { use n0_future::FutureExt; let coord_fut = async { RaceResult::CoordinatorMsg(scope.next().await) }; @@ -191,7 +191,7 @@ mod worker_impl { RaceResult::CoordinatorMsg(scope.next().await) }; - #[cfg(feature = "collab-worker")] + #[cfg(feature = "collab")] match race_result { RaceResult::CollabEvent(Some(event)) => { match event { @@ -281,7 +281,7 @@ mod worker_impl { continue; }; - let export_start = crate::perf::now(); + let export_start = weaver_common::perf::now(); let snapshot_bytes = match doc.export(loro::ExportMode::Snapshot) { Ok(bytes) => bytes, Err(e) => { @@ -298,11 +298,11 @@ mod worker_impl { continue; } }; - let export_ms = crate::perf::now() - export_start; + let export_ms = weaver_common::perf::now() - export_start; - let encode_start = crate::perf::now(); + let encode_start = weaver_common::perf::now(); let b64_snapshot = BASE64.encode(&snapshot_bytes); - let encode_ms = crate::perf::now() - encode_start; + let encode_ms = weaver_common::perf::now() - encode_start; let content = doc.get_text("content").to_string(); let title: SmolStr = doc.get_text("title").to_string().into(); @@ -327,9 +327,9 @@ mod worker_impl { } // ============================================================ - // Collab handlers - full impl when collab-worker feature enabled + // Collab handlers - full impl when collab feature enabled // ============================================================ - #[cfg(feature = "collab-worker")] + #[cfg(feature = "collab")] WorkerInput::StartCollab { topic, bootstrap_peers, @@ -389,9 +389,6 @@ mod worker_impl { ); } - // NOTE: Don't broadcast Join here - wait for BroadcastJoin message - // after peers have been added via AddPeers - // Create channel for events from spawned task let (event_tx, event_rx) = tokio::sync::mpsc::unbounded_channel(); @@ -461,8 +458,6 @@ mod worker_impl { selection, .. } => { - // Note: cursor updates require the collaborator to exist - // (added via Join message) let exists = presence.contains(&from); tracing::debug!(%from, position, ?selection, exists, "Received Cursor message"); presence.update_cursor( @@ -485,8 +480,6 @@ mod worker_impl { } SessionEvent::PeerJoined(peer) => { tracing::info!(%peer, "PeerJoined - notifying coordinator"); - // Notify coordinator so it can send BroadcastJoin - // Don't add to presence yet - wait for their Join message if event_tx .send(CollabEvent::PeerConnected) .is_err() @@ -531,7 +524,7 @@ mod worker_impl { } } - #[cfg(feature = "collab-worker")] + #[cfg(feature = "collab")] WorkerInput::BroadcastUpdate { data } => { if let Some(ref session) = collab_session { let msg = CollabMessage::LoroUpdate { @@ -544,7 +537,7 @@ mod worker_impl { } } - #[cfg(feature = "collab-worker")] + #[cfg(feature = "collab")] WorkerInput::BroadcastCursor { position, selection, @@ -572,22 +565,22 @@ mod worker_impl { } } - #[cfg(feature = "collab-worker")] + #[cfg(feature = "collab")] WorkerInput::AddPeers { peers } => { tracing::info!(count = peers.len(), "Worker: received AddPeers"); if let Some(ref session) = collab_session { let peer_ids: Vec<_> = peers - .iter() - .filter_map(|s| { - match parse_node_id(s) { - Ok(id) => Some(id), - Err(e) => { - tracing::warn!(node_id = %s, error = %e, "Failed to parse node_id"); - None - } - } - }) - .collect(); + .iter() + .filter_map(|s| { + match parse_node_id(s) { + Ok(id) => Some(id), + Err(e) => { + tracing::warn!(node_id = %s, error = %e, "Failed to parse node_id"); + None + } + } + }) + .collect(); tracing::info!( parsed_count = peer_ids.len(), "Worker: joining peers" @@ -600,7 +593,7 @@ mod worker_impl { } } - #[cfg(feature = "collab-worker")] + #[cfg(feature = "collab")] WorkerInput::BroadcastJoin { did, display_name } => { if let Some(ref session) = collab_session { let join_msg = CollabMessage::Join { did, display_name }; @@ -610,7 +603,7 @@ mod worker_impl { } } - #[cfg(feature = "collab-worker")] + #[cfg(feature = "collab")] WorkerInput::StopCollab => { collab_session = None; collab_node = None; @@ -619,12 +612,39 @@ mod worker_impl { tracing::error!("Failed to send CollabStopped to coordinator: {e}"); } } + + // Non-collab stubs for when collab feature is enabled but message doesn't match + #[cfg(not(feature = "collab"))] + WorkerInput::StartCollab { .. } => { + if let Err(e) = scope + .send(WorkerOutput::Error { + message: "Collab not enabled".into(), + }) + .await + { + tracing::error!("Failed to send Error to coordinator: {e}"); + } + } + #[cfg(not(feature = "collab"))] + WorkerInput::BroadcastUpdate { .. } => {} + #[cfg(not(feature = "collab"))] + WorkerInput::AddPeers { .. } => {} + #[cfg(not(feature = "collab"))] + WorkerInput::BroadcastJoin { .. } => {} + #[cfg(not(feature = "collab"))] + WorkerInput::BroadcastCursor { .. } => {} + #[cfg(not(feature = "collab"))] + WorkerInput::StopCollab => { + if let Err(e) = scope.send(WorkerOutput::CollabStopped).await { + tracing::error!("Failed to send CollabStopped to coordinator: {e}"); + } + } } // end match msg } // end RaceResult::CoordinatorMsg(Some(msg)) } // end match race_result - // Non-collab-worker: simple message loop - #[cfg(not(feature = "collab-worker"))] + // Non-collab: simple message loop + #[cfg(not(feature = "collab"))] { let Some(msg) = scope.next().await else { break }; tracing::debug!(?msg, "Worker: received message"); @@ -679,7 +699,7 @@ mod worker_impl { } continue; }; - let export_start = crate::perf::now(); + let export_start = weaver_common::perf::now(); let snapshot_bytes = match doc.export(loro::ExportMode::Snapshot) { Ok(bytes) => bytes, Err(e) => { @@ -696,10 +716,10 @@ mod worker_impl { continue; } }; - let export_ms = crate::perf::now() - export_start; - let encode_start = crate::perf::now(); + let export_ms = weaver_common::perf::now() - export_start; + let encode_start = weaver_common::perf::now(); let b64_snapshot = BASE64.encode(&snapshot_bytes); - let encode_ms = crate::perf::now() - encode_start; + let encode_ms = weaver_common::perf::now() - encode_start; let content = doc.get_text("content").to_string(); let title: SmolStr = doc.get_text("title").to_string().into(); if let Err(e) = scope @@ -720,7 +740,7 @@ mod worker_impl { tracing::error!("Failed to send Snapshot to coordinator: {e}"); } } - // Collab stubs for non-collab-worker build + // Collab stubs for non-collab build WorkerInput::StartCollab { .. } => { if let Err(e) = scope .send(WorkerOutput::Error { @@ -746,8 +766,9 @@ mod worker_impl { } /// Convert PresenceTracker to serializable PresenceSnapshot. - #[cfg(feature = "collab-worker")] + #[cfg(feature = "collab")] fn presence_to_snapshot(tracker: &PresenceTracker) -> PresenceSnapshot { + use jacquard::smol_str::ToSmolStr; let collaborators = tracker .collaborators() .map(|c| CollaboratorInfo { diff --git a/docs/graph-data.json b/docs/graph-data.json index e39a06b..7933d53 100644 --- a/docs/graph-data.json +++ b/docs/graph-data.json @@ -1605,6 +1605,105 @@ "created_at": "2026-01-06T13:29:10.289780508-05:00", "updated_at": "2026-01-06T13:29:10.289780508-05:00", "metadata_json": "{\"confidence\":100}" + }, + { + "id": 148, + "change_id": "ba10fea1-443c-4255-810a-1eaf0d5460eb", + "node_type": "observation", + "title": "Generic caching XrpcClient/Agent impl could be extracted to weaver-common - Fetcher's caching pattern is reusable", + "description": null, + "status": "pending", + "created_at": "2026-01-06T14:09:59.296473570-05:00", + "updated_at": "2026-01-06T14:09:59.296473570-05:00", + "metadata_json": "{\"confidence\":70}" + }, + { + "id": 149, + "change_id": "613058e8-44fb-4c27-9208-2fa17ef3a1f9", + "node_type": "decision", + "title": "weaver-editor-crdt crate design - generic CRDT/sync layer for AT Protocol apps", + "description": null, + "status": "pending", + "created_at": "2026-01-06T14:12:12.637577921-05:00", + "updated_at": "2026-01-06T14:12:12.637577921-05:00", + "metadata_json": "{\"confidence\":85}" + }, + { + "id": 150, + "change_id": "9b277ea3-0d58-4d30-8b81-2a589aed2942", + "node_type": "action", + "title": "Design documented in plans/2026-01-06-weaver-editor-crdt-design.md", + "description": null, + "status": "pending", + "created_at": "2026-01-06T14:12:12.655230849-05:00", + "updated_at": "2026-01-06T14:12:12.655230849-05:00", + "metadata_json": "{\"confidence\":95}" + }, + { + "id": 151, + "change_id": "601d0155-f773-4c6d-af26-aa45234d8b2a", + "node_type": "action", + "title": "WIP: Scaffolding weaver-editor-crdt crate - buffer.rs, document.rs, sync.rs created", + "description": null, + "status": "pending", + "created_at": "2026-01-06T14:17:26.809060420-05:00", + "updated_at": "2026-01-06T14:17:26.809060420-05:00", + "metadata_json": "{\"confidence\":75}" + }, + { + "id": 152, + "change_id": "321430d0-ecea-41db-baa3-48b146fb6cf2", + "node_type": "outcome", + "title": "weaver-editor-crdt scaffolded and compiling - buffer.rs, document.rs, sync.rs, worker/mod.rs", + "description": null, + "status": "pending", + "created_at": "2026-01-06T14:19:37.822498862-05:00", + "updated_at": "2026-01-06T14:19:37.822498862-05:00", + "metadata_json": "{\"confidence\":90}" + }, + { + "id": 153, + "change_id": "f357a30f-0000-4b09-bb35-fe6c23b1dccf", + "node_type": "outcome", + "title": "weaver-editor-crdt complete - LoroTextBuffer, CrdtDocument, sync, EditorReactor worker all extracted and building", + "description": null, + "status": "pending", + "created_at": "2026-01-06T14:26:25.977005171-05:00", + "updated_at": "2026-01-06T14:26:25.977005171-05:00", + "metadata_json": "{\"confidence\":95}" + }, + { + "id": 154, + "change_id": "ca300515-473e-4d26-b08e-24279d8d8ce7", + "node_type": "action", + "title": "Fixing EnvFilter error in editor_worker binary - adding env-filter feature to tracing-subscriber", + "description": null, + "status": "pending", + "created_at": "2026-01-06T14:31:53.901825123-05:00", + "updated_at": "2026-01-06T14:31:53.901825123-05:00", + "metadata_json": "{\"confidence\":90}" + }, + { + "id": 155, + "change_id": "8d336294-6ea4-4112-9120-99ba857836e7", + "node_type": "goal", + "title": "Extract worker manager from collab.rs to weaver-editor-crdt - non-Dioxus coordination logic", + "description": null, + "status": "pending", + "created_at": "2026-01-06T14:34:17.012426477-05:00", + "updated_at": "2026-01-06T14:34:17.012426477-05:00", + "metadata_json": "{\"confidence\":85}" + }, + { + "id": 156, + "change_id": "93f2714e-aacc-4982-88ae-69f3a06eb195", + "node_type": "outcome", + "title": "Worker build working - EditorReactor moved to weaver-editor-crdt, imports fixed in weaver-app", + "description": null, + "status": "pending", + "created_at": "2026-01-06T14:36:23.710415853-05:00", + "updated_at": "2026-01-06T14:36:23.710415853-05:00", + "metadata_json": "{\"confidence\":95}" } ], "edges": [ @@ -3312,6 +3411,72 @@ "weight": 1.0, "rationale": "Decision outcome", "created_at": "2026-01-06T13:29:10.392856789-05:00" + }, + { + "id": 157, + "from_node_id": 149, + "to_node_id": 150, + "from_change_id": "613058e8-44fb-4c27-9208-2fa17ef3a1f9", + "to_change_id": "9b277ea3-0d58-4d30-8b81-2a589aed2942", + "edge_type": "leads_to", + "weight": 1.0, + "rationale": "Design written to plans file", + "created_at": "2026-01-06T14:12:18.629424162-05:00" + }, + { + "id": 158, + "from_node_id": 149, + "to_node_id": 148, + "from_change_id": "613058e8-44fb-4c27-9208-2fa17ef3a1f9", + "to_change_id": "ba10fea1-443c-4255-810a-1eaf0d5460eb", + "edge_type": "leads_to", + "weight": 1.0, + "rationale": "Related future work identified during design", + "created_at": "2026-01-06T14:12:18.645517562-05:00" + }, + { + "id": 159, + "from_node_id": 151, + "to_node_id": 152, + "from_change_id": "601d0155-f773-4c6d-af26-aa45234d8b2a", + "to_change_id": "321430d0-ecea-41db-baa3-48b146fb6cf2", + "edge_type": "leads_to", + "weight": 1.0, + "rationale": "Scaffold completed", + "created_at": "2026-01-06T14:19:37.838628283-05:00" + }, + { + "id": 160, + "from_node_id": 149, + "to_node_id": 153, + "from_change_id": "613058e8-44fb-4c27-9208-2fa17ef3a1f9", + "to_change_id": "f357a30f-0000-4b09-bb35-fe6c23b1dccf", + "edge_type": "leads_to", + "weight": 1.0, + "rationale": "Implementation complete", + "created_at": "2026-01-06T14:26:25.993259775-05:00" + }, + { + "id": 161, + "from_node_id": 154, + "to_node_id": 155, + "from_change_id": "ca300515-473e-4d26-b08e-24279d8d8ce7", + "to_change_id": "8d336294-6ea4-4112-9120-99ba857836e7", + "edge_type": "leads_to", + "weight": 1.0, + "rationale": "Part of same extraction session", + "created_at": "2026-01-06T14:34:17.133444402-05:00" + }, + { + "id": 162, + "from_node_id": 154, + "to_node_id": 156, + "from_change_id": "ca300515-473e-4d26-b08e-24279d8d8ce7", + "to_change_id": "93f2714e-aacc-4982-88ae-69f3a06eb195", + "edge_type": "leads_to", + "weight": 1.0, + "rationale": "Completed fix", + "created_at": "2026-01-06T14:36:23.726201054-05:00" } ] } \ No newline at end of file