diff --git a/Cargo.lock b/Cargo.lock index 124de03..7805c84 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11527,6 +11527,7 @@ dependencies = [ "dioxus-sdk", "dotenvy", "fontdb", + "futures-util", "getrandom 0.3.4", "gloo-storage", "gloo-timers", diff --git a/build-workers.sh b/build-workers.sh index 40e21c6..2284298 100755 --- a/build-workers.sh +++ b/build-workers.sh @@ -5,7 +5,7 @@ echo "==> Building worker WASMs" export RUSTFLAGS='--cfg getrandom_backend="wasm_js"' cargo build -p weaver-app --bin editor_worker --bin embed_worker \ --target wasm32-unknown-unknown --release \ - --no-default-features --features "web" + --no-default-features --features "web","collab-worker" echo "==> Running wasm-bindgen" wasm-bindgen target/wasm32-unknown-unknown/release/editor_worker.wasm \ diff --git a/crates/weaver-app/.env-dev b/crates/weaver-app/.env-dev deleted file mode 100644 index 5d82bc4..0000000 --- a/crates/weaver-app/.env-dev +++ /dev/null @@ -1,10 +0,0 @@ -WEAVER_APP_ENV="dev" -WEAVER_APP_HOST="http://localhost" -WEAVER_APP_DOMAIN="" -WEAVER_PORT=8080 -WEAVER_APP_SCOPES="atproto transition:generic" -WEAVER_CLIENT_NAME="Weaver" - -WEAVER_LOGO_URI="" -WEAVER_TOS_URI="" -WEAVER_PRIVACY_POLICY_URI="" diff --git a/crates/weaver-app/.env-prod b/crates/weaver-app/.env-prod new file mode 100644 index 0000000..76ce3db --- /dev/null +++ b/crates/weaver-app/.env-prod @@ -0,0 +1,10 @@ +WEAVER_APP_ENV="prod" +WEAVER_APP_HOST="https://alpha.weaver.sh" +WEAVER_APP_DOMAIN="https://alpha.weaver.sh" +WEAVER_PORT=8080 +WEAVER_APP_SCOPES="atproto transition:generic" +WEAVER_CLIENT_NAME="Weaver" + +WEAVER_LOGO_URI="https://alpha.weaver.sh/favicon.ico" +WEAVER_TOS_URI="" +WEAVER_PRIVACY_POLICY_URI="" diff --git a/crates/weaver-app/Cargo.toml b/crates/weaver-app/Cargo.toml index 49a3a78..a6e09cf 100644 --- a/crates/weaver-app/Cargo.toml +++ b/crates/weaver-app/Cargo.toml @@ -75,6 +75,7 @@ web-time = "1.1" urlencoding = "2.1" tracing-subscriber = { version = "0.3", default-features = false, features = ["std", "registry"] } dioxus-sdk = { version = "0.7", features = ["time"] } +futures-util = "0.3" # OG image generation (server-only) resvg = { version = "0.44", optional = true } @@ -99,7 +100,7 @@ mini-moka = { git = "https://github.com/moka-rs/mini-moka", rev = "da864e849f5d0 chrono = { version = "0.4", features = ["wasmbind"] } wasm-bindgen = "0.2" wasm-bindgen-futures = "0.4" -web-sys = { version = "0.3", features = ["ServiceWorkerContainer", "ServiceWorker", "ServiceWorkerRegistration", "RegistrationOptions", "Window", "Navigator", "MessageEvent", "console", "Document", "Element", "HtmlImageElement", "Selection", "Range", "Node", "HtmlElement", "TreeWalker", "NodeFilter", "DomTokenList", "Clipboard", "ClipboardItem", "Blob", "BlobPropertyBag", "EventTarget", "InputEvent", "AddEventListenerOptions", "DomRect", "DomRectList"] } +web-sys = { version = "0.3", features = ["ServiceWorkerContainer", "ServiceWorker", "ServiceWorkerRegistration", "RegistrationOptions", "Window", "Navigator", "MessageEvent", "console", "Document", "Element", "HtmlImageElement", "Selection", "Range", "Node", "HtmlElement", "TreeWalker", "NodeFilter", "DomTokenList", "Clipboard", "ClipboardItem", "Blob", "BlobPropertyBag", "EventTarget", "InputEvent", "AddEventListenerOptions", "DomRect", "DomRectList", "Performance"] } js-sys = "0.3" gloo-storage = "0.3" gloo-timers = "0.3" diff --git a/crates/weaver-app/public/editor_worker.js b/crates/weaver-app/public/editor_worker.js index b79aa8d..ba43887 100644 --- a/crates/weaver-app/public/editor_worker.js +++ b/crates/weaver-app/public/editor_worker.js @@ -17,6 +17,71 @@ 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); @@ -167,20 +232,52 @@ let wasm_bindgen; let WASM_VECTOR_LEN = 0; - function wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke___wasm_bindgen_1add006a0ed82fd3___JsValue_____(arg0, arg1, arg2) { - wasm.wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke___wasm_bindgen_1add006a0ed82fd3___JsValue_____(arg0, arg1, arg2); + function wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke_______3_(arg0, arg1) { + wasm.wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke_______3_(arg0, arg1); } function wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke___web_sys_1984c39bba2ffe3a___features__gen_MessageEvent__MessageEvent_____(arg0, arg1, arg2) { wasm.wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke___web_sys_1984c39bba2ffe3a___features__gen_MessageEvent__MessageEvent_____(arg0, arg1, arg2); } + function wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke___wasm_bindgen_1add006a0ed82fd3___JsValue_____(arg0, arg1, arg2) { + wasm.wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke___wasm_bindgen_1add006a0ed82fd3___JsValue_____(arg0, arg1, arg2); + } + + function wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke______(arg0, arg1) { + wasm.wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke______(arg0, arg1); + } + + function wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke___web_sys_1984c39bba2ffe3a___features__gen_MessageEvent__MessageEvent______1_(arg0, arg1, arg2) { + wasm.wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke___web_sys_1984c39bba2ffe3a___features__gen_MessageEvent__MessageEvent______1_(arg0, arg1, arg2); + } + + function wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke_______2_(arg0, arg1) { + wasm.wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke_______2_(arg0, arg1); + } + + function wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke___web_sys_1984c39bba2ffe3a___features__gen_CloseEvent__CloseEvent_____(arg0, arg1, arg2) { + wasm.wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke___web_sys_1984c39bba2ffe3a___features__gen_CloseEvent__CloseEvent_____(arg0, arg1, arg2); + } + + function wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke_______1_(arg0, arg1) { + wasm.wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke_______1_(arg0, arg1); + } + function wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke___wasm_bindgen_1add006a0ed82fd3___JsValue__wasm_bindgen_1add006a0ed82fd3___JsValue_____(arg0, arg1, arg2, arg3) { wasm.wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke___wasm_bindgen_1add006a0ed82fd3___JsValue__wasm_bindgen_1add006a0ed82fd3___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)); @@ -361,6 +458,18 @@ 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; @@ -378,12 +487,40 @@ 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; @@ -408,15 +545,41 @@ 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_7a42b49784aea641 = 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; @@ -425,6 +588,10 @@ 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) }; @@ -439,9 +606,74 @@ let wasm_bindgen; wasm.__wbindgen_free(deferred0_0, deferred0_1, 1); } }; + imports.wbg.__wbg_fetch_74a3e84ebd2c9a0e = 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 { @@ -452,14 +684,90 @@ 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; }; + imports.wbg.__wbg_log_0cc1b7768397bcfe = function(arg0, arg1, arg2, arg3, arg4, arg5, arg6, arg7) { + let deferred0_0; + let deferred0_1; + try { + deferred0_0 = arg0; + deferred0_1 = arg1; + console.log(getStringFromWasm0(arg0, arg1), getStringFromWasm0(arg2, arg3), getStringFromWasm0(arg4, arg5), getStringFromWasm0(arg6, arg7)); + } finally { + wasm.__wbindgen_free(deferred0_0, deferred0_1, 1); + } + }; + imports.wbg.__wbg_log_cb9e190acc5753fb = function(arg0, arg1) { + let deferred0_0; + let deferred0_1; + try { + deferred0_0 = arg0; + deferred0_1 = arg1; + console.log(getStringFromWasm0(arg0, arg1)); + } finally { + wasm.__wbindgen_free(deferred0_0, deferred0_1, 1); + } + }; + imports.wbg.__wbg_mark_7438147ce31e9d4b = function(arg0, arg1) { + performance.mark(getStringFromWasm0(arg0, arg1)); + }; + imports.wbg.__wbg_measure_fb7825c11612c823 = function() { return handleError(function (arg0, arg1, arg2, arg3) { + let deferred0_0; + let deferred0_1; + let deferred1_0; + let deferred1_1; + try { + deferred0_0 = arg0; + deferred0_1 = arg1; + deferred1_0 = arg2; + deferred1_1 = arg3; + performance.measure(getStringFromWasm0(arg0, arg1), getStringFromWasm0(arg2, arg3)); + } finally { + wasm.__wbindgen_free(deferred0_0, deferred0_1, 1); + 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; @@ -502,10 +810,34 @@ 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_8a87c5466cc7d560 = function() { const ret = Date.now(); return ret; @@ -514,6 +846,10 @@ let wasm_bindgen; const ret = arg0.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); @@ -528,6 +864,10 @@ 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; @@ -538,6 +878,27 @@ 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; @@ -549,12 +910,69 @@ 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_7bb3429662ab1e70 = 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); @@ -578,14 +996,44 @@ 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; @@ -594,21 +1042,55 @@ let wasm_bindgen; const ret = arg0.view; return isLikeNone(ret) ? 0 : addToExternrefTable0(ret); }; - imports.wbg.__wbindgen_cast_1201d4f342f74eb8 = function(arg0, arg1) { - // Cast intrinsic for `Closure(Closure { dtor_idx: 909, function: Function { arguments: [Externref], shim_idx: 910, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. + imports.wbg.__wbg_wasClean_4154a2d59fdb4dd7 = function(arg0) { + const ret = arg0.wasClean; + return ret; + }; + imports.wbg.__wbindgen_cast_0040fcf5dbb58dd5 = function(arg0, arg1) { + // Cast intrinsic for `Closure(Closure { dtor_idx: 5300, function: Function { arguments: [Externref], shim_idx: 5301, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. const ret = makeMutClosure(arg0, arg1, wasm.wasm_bindgen_1add006a0ed82fd3___closure__destroy___dyn_core_b125d98f3949a913___ops__function__FnMut__wasm_bindgen_1add006a0ed82fd3___JsValue____Output_______, wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke___wasm_bindgen_1add006a0ed82fd3___JsValue_____); return ret; }; + imports.wbg.__wbindgen_cast_12196eb5081a2539 = function(arg0, arg1) { + // Cast intrinsic for `Closure(Closure { dtor_idx: 3492, function: Function { arguments: [], shim_idx: 3493, ret: Unit, inner_ret: Some(Unit) }, mutable: false }) -> Externref`. + const ret = makeClosure(arg0, arg1, wasm.wasm_bindgen_1add006a0ed82fd3___closure__destroy___dyn_core_b125d98f3949a913___ops__function__Fn_____Output_______, wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke_______1_); + return ret; + }; + imports.wbg.__wbindgen_cast_19a2ac9a2d117d63 = function(arg0, arg1) { + // Cast intrinsic for `Closure(Closure { dtor_idx: 3436, function: Function { arguments: [NamedExternref("CloseEvent")], shim_idx: 3437, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. + const ret = makeMutClosure(arg0, arg1, wasm.wasm_bindgen_1add006a0ed82fd3___closure__destroy___dyn_core_b125d98f3949a913___ops__function__FnMut__web_sys_1984c39bba2ffe3a___features__gen_CloseEvent__CloseEvent____Output_______, wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke___web_sys_1984c39bba2ffe3a___features__gen_CloseEvent__CloseEvent_____); + 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_7486151126cca30f = function(arg0, arg1) { - // Cast intrinsic for `Closure(Closure { dtor_idx: 66, function: Function { arguments: [NamedExternref("MessageEvent")], shim_idx: 67, ret: Unit, inner_ret: Some(Unit) }, mutable: false }) -> Externref`. + imports.wbg.__wbindgen_cast_349b5f2a8c1e5597 = function(arg0, arg1) { + // Cast intrinsic for `Closure(Closure { dtor_idx: 3990, function: Function { arguments: [NamedExternref("MessageEvent")], shim_idx: 3991, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. + const ret = makeMutClosure(arg0, arg1, wasm.wasm_bindgen_1add006a0ed82fd3___closure__destroy___dyn_core_b125d98f3949a913___ops__function__FnMut__web_sys_1984c39bba2ffe3a___features__gen_MessageEvent__MessageEvent____Output_______, wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke___web_sys_1984c39bba2ffe3a___features__gen_MessageEvent__MessageEvent______1_); + return ret; + }; + imports.wbg.__wbindgen_cast_70089f6cfcc0d4b7 = function(arg0, arg1) { + // Cast intrinsic for `Closure(Closure { dtor_idx: 391, function: Function { arguments: [NamedExternref("MessageEvent")], shim_idx: 392, ret: Unit, inner_ret: Some(Unit) }, mutable: false }) -> Externref`. const ret = makeClosure(arg0, arg1, wasm.wasm_bindgen_1add006a0ed82fd3___closure__destroy___dyn_core_b125d98f3949a913___ops__function__Fn__web_sys_1984c39bba2ffe3a___features__gen_MessageEvent__MessageEvent____Output_______, wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke___web_sys_1984c39bba2ffe3a___features__gen_MessageEvent__MessageEvent_____); return ret; }; + imports.wbg.__wbindgen_cast_89505235e1110442 = function(arg0, arg1) { + // Cast intrinsic for `Closure(Closure { dtor_idx: 3469, function: Function { arguments: [], shim_idx: 3470, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. + const ret = makeMutClosure(arg0, arg1, wasm.wasm_bindgen_1add006a0ed82fd3___closure__destroy___dyn_core_b125d98f3949a913___ops__function__FnMut_____Output_______, wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke______); + return ret; + }; + imports.wbg.__wbindgen_cast_9ce2f96e3ea0b3c4 = function(arg0, arg1) { + // Cast intrinsic for `Closure(Closure { dtor_idx: 5120, function: Function { arguments: [], shim_idx: 5121, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. + const ret = makeMutClosure(arg0, arg1, wasm.wasm_bindgen_1add006a0ed82fd3___closure__destroy___dyn_core_b125d98f3949a913___ops__function__FnMut_____Output________2_, wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke_______3_); + return ret; + }; + imports.wbg.__wbindgen_cast_b5e2cf29d57ca6af = function(arg0, arg1) { + // Cast intrinsic for `Closure(Closure { dtor_idx: 5007, function: Function { arguments: [], shim_idx: 5008, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. + const ret = makeMutClosure(arg0, arg1, wasm.wasm_bindgen_1add006a0ed82fd3___closure__destroy___dyn_core_b125d98f3949a913___ops__function__FnMut_____Output________1_, wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke_______2_); + return ret; + }; imports.wbg.__wbindgen_cast_cb9088102bce6b30 = function(arg0, arg1) { // Cast intrinsic for `Ref(Slice(U8)) -> NamedExternref("Uint8Array")`. const ret = getArrayU8FromWasm0(arg0, arg1); diff --git a/crates/weaver-app/public/embed_worker.js b/crates/weaver-app/public/embed_worker.js index e465198..abf165d 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_1add006a0ed82fd3___convert__closures_____invoke______(arg0, arg1) { - wasm.wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke______(arg0, arg1); - } - - function wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke___web_sys_1984c39bba2ffe3a___features__gen_MessageEvent__MessageEvent_____(arg0, arg1, arg2) { - wasm.wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke___web_sys_1984c39bba2ffe3a___features__gen_MessageEvent__MessageEvent_____(arg0, arg1, arg2); - } - function wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke_______1_(arg0, arg1) { wasm.wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke_______1_(arg0, arg1); } @@ -248,6 +240,14 @@ let wasm_bindgen; wasm.wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke___wasm_bindgen_1add006a0ed82fd3___JsValue_____(arg0, arg1, arg2); } + function wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke___web_sys_1984c39bba2ffe3a___features__gen_MessageEvent__MessageEvent_____(arg0, arg1, arg2) { + wasm.wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke___web_sys_1984c39bba2ffe3a___features__gen_MessageEvent__MessageEvent_____(arg0, arg1, arg2); + } + + function wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke______(arg0, arg1) { + wasm.wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke______(arg0, arg1); + } + function wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke___wasm_bindgen_1add006a0ed82fd3___JsValue__wasm_bindgen_1add006a0ed82fd3___JsValue_____(arg0, arg1, arg2, arg3) { wasm.wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke___wasm_bindgen_1add006a0ed82fd3___JsValue__wasm_bindgen_1add006a0ed82fd3___JsValue_____(arg0, arg1, arg2, arg3); } @@ -809,24 +809,24 @@ let wasm_bindgen; const ret = getStringFromWasm0(arg0, arg1); return ret; }; - imports.wbg.__wbindgen_cast_58535cde6944cfce = function(arg0, arg1) { - // Cast intrinsic for `Closure(Closure { dtor_idx: 2267, function: Function { arguments: [Externref], shim_idx: 2268, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. + imports.wbg.__wbindgen_cast_28fcab414066dd47 = function(arg0, arg1) { + // Cast intrinsic for `Closure(Closure { dtor_idx: 2276, function: Function { arguments: [Externref], shim_idx: 2277, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. const ret = makeMutClosure(arg0, arg1, wasm.wasm_bindgen_1add006a0ed82fd3___closure__destroy___dyn_core_b125d98f3949a913___ops__function__FnMut__wasm_bindgen_1add006a0ed82fd3___JsValue____Output_______, wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke___wasm_bindgen_1add006a0ed82fd3___JsValue_____); return ret; }; - imports.wbg.__wbindgen_cast_bd3d7d0f0ebc68be = function(arg0, arg1) { - // Cast intrinsic for `Closure(Closure { dtor_idx: 1185, function: Function { arguments: [], shim_idx: 1186, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. - const ret = makeMutClosure(arg0, arg1, wasm.wasm_bindgen_1add006a0ed82fd3___closure__destroy___dyn_core_b125d98f3949a913___ops__function__FnMut_____Output_______, wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke______); + imports.wbg.__wbindgen_cast_80e60da953ba3dd9 = function(arg0, arg1) { + // Cast intrinsic for `Closure(Closure { dtor_idx: 310, function: Function { arguments: [NamedExternref("MessageEvent")], shim_idx: 311, ret: Unit, inner_ret: Some(Unit) }, mutable: false }) -> Externref`. + const ret = makeClosure(arg0, arg1, wasm.wasm_bindgen_1add006a0ed82fd3___closure__destroy___dyn_core_b125d98f3949a913___ops__function__Fn__web_sys_1984c39bba2ffe3a___features__gen_MessageEvent__MessageEvent____Output_______, wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke___web_sys_1984c39bba2ffe3a___features__gen_MessageEvent__MessageEvent_____); return ret; }; - imports.wbg.__wbindgen_cast_c5ee62b4aaae9530 = function(arg0, arg1) { - // Cast intrinsic for `Closure(Closure { dtor_idx: 1445, function: Function { arguments: [], shim_idx: 1446, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. - const ret = makeMutClosure(arg0, arg1, wasm.wasm_bindgen_1add006a0ed82fd3___closure__destroy___dyn_core_b125d98f3949a913___ops__function__FnMut_____Output________1_, wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke_______1_); + imports.wbg.__wbindgen_cast_a731521d4dc80277 = function(arg0, arg1) { + // Cast intrinsic for `Closure(Closure { dtor_idx: 1194, function: Function { arguments: [], shim_idx: 1195, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. + const ret = makeMutClosure(arg0, arg1, wasm.wasm_bindgen_1add006a0ed82fd3___closure__destroy___dyn_core_b125d98f3949a913___ops__function__FnMut_____Output_______, wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke______); return ret; }; - imports.wbg.__wbindgen_cast_cf4ca89562bb2a76 = function(arg0, arg1) { - // Cast intrinsic for `Closure(Closure { dtor_idx: 419, function: Function { arguments: [NamedExternref("MessageEvent")], shim_idx: 420, ret: Unit, inner_ret: Some(Unit) }, mutable: false }) -> Externref`. - const ret = makeClosure(arg0, arg1, wasm.wasm_bindgen_1add006a0ed82fd3___closure__destroy___dyn_core_b125d98f3949a913___ops__function__Fn__web_sys_1984c39bba2ffe3a___features__gen_MessageEvent__MessageEvent____Output_______, wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke___web_sys_1984c39bba2ffe3a___features__gen_MessageEvent__MessageEvent_____); + imports.wbg.__wbindgen_cast_e0273882c29884ec = function(arg0, arg1) { + // Cast intrinsic for `Closure(Closure { dtor_idx: 1454, function: Function { arguments: [], shim_idx: 1455, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. + const ret = makeMutClosure(arg0, arg1, wasm.wasm_bindgen_1add006a0ed82fd3___closure__destroy___dyn_core_b125d98f3949a913___ops__function__FnMut_____Output________1_, wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke_______1_); return ret; }; imports.wbg.__wbindgen_init_externref_table = function() { diff --git a/crates/weaver-app/src/bin/editor_worker.rs b/crates/weaver-app/src/bin/editor_worker.rs index 129b240..883e24d 100644 --- a/crates/weaver-app/src/bin/editor_worker.rs +++ b/crates/weaver-app/src/bin/editor_worker.rs @@ -6,11 +6,37 @@ #[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_app::components::editor::EditorWorker; + use weaver_app::components::editor::EditorReactor; - EditorWorker::registrar().register(); + EditorReactor::registrar().register(); } #[cfg(not(all(target_family = "wasm", target_os = "unknown")))] diff --git a/crates/weaver-app/src/collab_context.rs b/crates/weaver-app/src/collab_context.rs index 4eda0d3..40f6439 100644 --- a/crates/weaver-app/src/collab_context.rs +++ b/crates/weaver-app/src/collab_context.rs @@ -1,13 +1,9 @@ -//! Real-time collaboration context for P2P editing sessions. +//! Real-time collaboration debug state. //! -//! This module provides the CollabNode as a Dioxus context, allowing editor -//! components to join gossip sessions for real-time collaboration. -//! -//! The CollabNode is only active in WASM builds where iroh works via relays. +//! This module provides CollabDebugState which is set as context by +//! the CollabCoordinator component for display in the editor debug panel. use dioxus::prelude::*; -use std::sync::Arc; -use weaver_common::transport::CollabNode; /// Debug state for the collab session, displayed in editor debug panel. #[derive(Clone, Default)] @@ -28,95 +24,8 @@ pub struct CollabDebugState { pub last_error: Option, } -/// Context state for the collaboration node. -/// -/// This is provided as a Dioxus context and can be accessed by editor components -/// to join/leave collaborative editing sessions. -#[derive(Clone)] -pub struct CollabContext { - /// The collaboration node, if successfully spawned. - /// None while loading or if spawn failed. - pub node: Option>, - /// Error message if spawn failed. - pub error: Option, -} - -impl Default for CollabContext { - fn default() -> Self { - Self { - node: None, - error: None, - } - } -} - -/// Provider component that spawns the CollabNode and provides it as context. -/// -/// Should be placed near the root of the app, wrapping any components that -/// need access to real-time collaboration. -#[cfg(all(target_arch = "wasm32", target_os = "unknown"))] -#[component] -pub fn CollabProvider(children: Element) -> Element { - let mut collab_ctx = use_signal(CollabContext::default); - let debug_state = use_signal(CollabDebugState::default); - - // Spawn the CollabNode on mount - let _spawn_result = use_resource(move || async move { - tracing::info!("Spawning CollabNode..."); - - match CollabNode::spawn(None).await { - Ok(node) => { - tracing::info!(node_id = %node.node_id_string(), "CollabNode spawned"); - collab_ctx.set(CollabContext { - node: Some(node), - error: None, - }); - } - Err(e) => { - tracing::error!("Failed to spawn CollabNode: {}", e); - collab_ctx.set(CollabContext { - node: None, - error: Some(e.to_string()), - }); - } - } - }); - - // Provide the contexts - use_context_provider(|| collab_ctx); - use_context_provider(|| debug_state); - - rsx! { {children} } -} - -/// No-op provider for non-WASM builds. -#[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))] -#[component] -pub fn CollabProvider(children: Element) -> Element { - // On server/native, provide an empty context (collab happens in browser) - let collab_ctx = use_signal(CollabContext::default); - let debug_state = use_signal(CollabDebugState::default); - use_context_provider(|| collab_ctx); - use_context_provider(|| debug_state); - rsx! { {children} } -} - -/// Hook to get the CollabNode from context. -/// -/// Returns None if the node hasn't spawned yet or failed to spawn. -pub fn use_collab_node() -> Option> { - let ctx = use_context::>(); - ctx.read().node.clone() -} - -/// Hook to check if collab is available. -pub fn use_collab_available() -> bool { - let ctx = use_context::>(); - ctx.read().node.is_some() -} - /// Hook to get the collab debug state signal. -/// Returns None if called outside CollabProvider. +/// Returns None if called outside CollabCoordinator. pub fn try_use_collab_debug() -> Option> { try_use_context::>() } diff --git a/crates/weaver-app/src/components/editor/collab.rs b/crates/weaver-app/src/components/editor/collab.rs new file mode 100644 index 0000000..72c4070 --- /dev/null +++ b/crates/weaver-app/src/components/editor/collab.rs @@ -0,0 +1,545 @@ +//! Collab coordinator - bridges EditorWorker and authenticated PDS ops. +//! +//! This component handles the main-thread side of real-time collaboration: +//! - Spawns the editor worker and manages its lifecycle +//! - Performs authenticated PDS operations (session records, peer discovery) +//! - Forwards local Loro updates to the worker for gossip broadcast +//! - Receives remote updates from worker and applies to main document +//! - Provides CollabDebugState context for debug UI +//! +//! The worker handles all iroh/gossip networking off the main thread. + +// Only compile for WASM - no-op stub provided at end + +use super::document::EditorDocument; + +use dioxus::prelude::*; + +#[cfg(target_arch = "wasm32")] +use jacquard::types::string::AtUri; + +use weaver_common::transport::PresenceSnapshot; + +/// Session record TTL in minutes. +#[cfg(target_arch = "wasm32")] +const SESSION_TTL_MINUTES: u32 = 15; + +/// How often to refresh session record (ms). +#[cfg(target_arch = "wasm32")] +const SESSION_REFRESH_INTERVAL_MS: u32 = 5 * 60 * 1000; // 5 minutes + +/// How often to poll for new peers (ms). +#[cfg(target_arch = "wasm32")] +const PEER_DISCOVERY_INTERVAL_MS: u32 = 30 * 1000; // 30 seconds + +/// Props for the CollabCoordinator component. +#[derive(Props, Clone, PartialEq)] +pub struct CollabCoordinatorProps { + /// The editor document to sync + pub document: EditorDocument, + /// Resource URI for the document being edited + pub resource_uri: String, + /// Presence state signal (updated by coordinator) + pub presence: Signal, + /// Children to render (this component wraps the editor) + pub children: Element, +} + +/// Coordinator state machine states. +#[cfg(target_arch = "wasm32")] +#[derive(Debug, Clone, PartialEq)] +enum CoordinatorState { + /// Initial state - waiting for worker to be ready + Initializing, + /// Creating session record on PDS + CreatingSession { + node_id: String, + relay_url: Option, + }, + /// Active collab session + Active { session_uri: AtUri<'static> }, + /// Error state + Error(String), +} + +/// Coordinator component that bridges worker and PDS. +/// +/// This is a wrapper component that: +/// 1. Provides CollabDebugState context +/// 2. Manages collab lifecycle (worker, PDS records, peer discovery) +/// 3. Renders children +/// +/// Lifecycle: +/// 1. Worker spawned on mount, sends CollabReady with node_id +/// 2. Coordinator creates session record on PDS +/// 3. Coordinator discovers existing peers +/// 4. Worker joins gossip session +/// 5. Local updates forwarded to worker via subscribe_local_update +/// 6. Remote updates from worker applied to main document +/// 7. Session record deleted on unmount +#[component] +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; + use futures_util::{SinkExt, StreamExt}; + use gloo_worker::Spawnable; + use gloo_worker::reactor::ReactorBridge; + use jacquard::IntoStatic; + use weaver_common::WeaverExt; + + use super::worker::EditorReactor; + + let fetcher = use_context::(); + + // Provide debug state context + let mut debug_state = use_signal(CollabDebugState::default); + use_context_provider(|| debug_state); + + // Coordinator state + let mut state: Signal = use_signal(|| CoordinatorState::Initializing); + + // Worker sink for sending messages - Signal persists across renders + type WorkerSink = SplitSink, WorkerInput>; + let mut worker_sink: Signal> = use_signal(|| None); + + // Session record URI for cleanup + let mut session_uri: Signal>> = use_signal(|| None); + + // Loro subscription handle (keep alive) + let mut loro_sub: Signal> = use_signal(|| None); + + // Clone for closures + let resource_uri = props.resource_uri.clone(); + let mut doc = props.document.clone(); + let mut presence = props.presence; + + // Spawn worker and set up message handling + let fetcher_for_spawn = fetcher.clone(); + let resource_uri_for_spawn = resource_uri.clone(); + use_effect(move || { + let mut worker_sink = worker_sink; + let fetcher = fetcher_for_spawn.clone(); + let resource_uri = resource_uri_for_spawn.clone(); + // Channel for local updates (Loro callback is Send+Sync, but ReactorBridge isn't) + let (local_update_tx, mut local_update_rx) = + tokio::sync::mpsc::unbounded_channel::>(); + + let tx = local_update_tx.clone(); + + // Subscribe to local Loro updates - just send to channel (Send+Sync) + let sub = doc + .loro_doc() + .subscribe_local_update(Box::new(move |update| { + let _ = tx.send(update.to_vec()); + true // Keep subscription active + })); + + loro_sub.set(Some(sub)); + + // Spawn the reactor + let bridge = EditorReactor::spawner().spawn("/editor_worker.js"); + let (sink, mut stream) = bridge.split(); + worker_sink.set(Some(sink)); + + // Initialize worker with current document snapshot + let snapshot = doc.export_snapshot(); + let draft_key = resource_uri.clone(); // Use resource URI as the key + spawn(async move { + if let Some(ref mut sink) = *worker_sink.write() { + if let Err(e) = sink + .send(WorkerInput::Init { + snapshot, + draft_key, + }) + .await + { + tracing::error!("Failed to send Init to worker: {e}"); + } + } + }); + + // Task 1: Forward local updates from channel to worker + spawn(async move { + while let Some(data) = local_update_rx.recv().await { + if let Some(ref mut s) = *worker_sink.write() { + if let Err(e) = s.send(WorkerInput::BroadcastUpdate { data }).await { + tracing::warn!("Failed to send BroadcastUpdate to worker: {e}"); + } + } + } + }); + + // Task 2: Handle worker output messages + let doc_for_handler = doc.clone(); + spawn(async move { + let mut doc = doc_for_handler; + while let Some(output) = stream.next().await { + match output { + WorkerOutput::Ready => { + tracing::info!("CollabCoordinator: worker ready, starting collab"); + + // Compute topic from resource URI + let hash = weaver_common::blake3::hash(resource_uri.as_bytes()); + let topic: [u8; 32] = *hash.as_bytes(); + + // Send StartCollab to worker immediately (no blocking on profile fetch) + if let Some(ref mut s) = *worker_sink.write() { + if let Err(e) = s + .send(WorkerInput::StartCollab { + topic, + bootstrap_peers: vec![], + }) + .await + { + tracing::error!("Failed to send StartCollab to worker: {e}"); + } + } + } + + WorkerOutput::CollabReady { node_id, relay_url } => { + tracing::info!( + node_id = %node_id, + relay_url = ?relay_url, + "CollabCoordinator: collab node ready" + ); + + // Update debug state + debug_state.with_mut(|ds| { + ds.node_id = Some(node_id.clone()); + ds.relay_url = relay_url.clone(); + }); + + state.set(CoordinatorState::CreatingSession { + node_id: node_id.clone(), + relay_url: relay_url.clone(), + }); + + // Create session record on PDS + let fetcher = fetcher.clone(); + let resource_uri = resource_uri.clone(); + + spawn(async move { + // Parse resource URI to get StrongRef + let uri = match AtUri::new(&resource_uri) { + Ok(u) => u.into_static(), + Err(e) => { + let err = format!("Invalid resource URI: {e}"); + debug_state + .with_mut(|ds| ds.last_error = Some(err.clone())); + state.set(CoordinatorState::Error(err)); + return; + } + }; + + // Get StrongRef for the resource + let strong_ref = match fetcher.confirm_record_ref(&uri).await { + Ok(r) => r, + Err(e) => { + let err = format!("Failed to get resource ref: {e}"); + debug_state + .with_mut(|ds| ds.last_error = Some(err.clone())); + state.set(CoordinatorState::Error(err)); + return; + } + }; + + // Create session record + match fetcher + .create_collab_session( + &strong_ref, + &node_id, + relay_url.as_deref(), + Some(SESSION_TTL_MINUTES), + ) + .await + { + Ok(session_record_uri) => { + tracing::info!( + uri = %session_record_uri, + "CollabCoordinator: session record created" + ); + session_uri.set(Some(session_record_uri.clone())); + debug_state.with_mut(|ds| { + ds.session_record_uri = + Some(session_record_uri.to_string()); + }); + + // Discover existing peers + let bootstrap_peers = match fetcher + .find_session_peers(&uri) + .await + { + Ok(peers) => { + tracing::info!( + count = peers.len(), + "CollabCoordinator: found peers" + ); + debug_state.with_mut(|ds| { + ds.discovered_peers = peers.len(); + }); + peers + .into_iter() + .map(|p| p.node_id) + .collect::>() + } + Err(e) => { + tracing::warn!( + "CollabCoordinator: peer discovery failed: {e}" + ); + vec![] + } + }; + + // Send discovered peers to worker + if !bootstrap_peers.is_empty() { + tracing::info!( + count = bootstrap_peers.len(), + peers = ?bootstrap_peers, + "CollabCoordinator: sending AddPeers to worker" + ); + if let Some(ref mut s) = *worker_sink.write() { + if let Err(e) = s + .send(WorkerInput::AddPeers { + peers: bootstrap_peers, + }) + .await + { + tracing::error!("CollabCoordinator: AddPeers send failed: {e}"); + } + } else { + tracing::error!("CollabCoordinator: sink is None!"); + } + } else { + tracing::info!("CollabCoordinator: no peers to add"); + } + + state.set(CoordinatorState::Active { + session_uri: session_record_uri, + }); + } + Err(e) => { + let err = format!("Failed to create session: {e}"); + debug_state + .with_mut(|ds| ds.last_error = Some(err.clone())); + state.set(CoordinatorState::Error(err)); + } + } + }); + } + + WorkerOutput::CollabJoined => { + tracing::info!("CollabCoordinator: joined gossip session"); + debug_state.with_mut(|ds| ds.is_joined = true); + } + + WorkerOutput::RemoteUpdates { data } => { + if let Err(e) = doc.import_updates(&data) { + tracing::warn!( + "CollabCoordinator: failed to import updates: {:?}", + e + ); + } + } + + WorkerOutput::PresenceUpdate(snapshot) => { + debug_state.with_mut(|ds| { + ds.connected_peers = snapshot.peer_count; + }); + presence.set(snapshot); + } + + WorkerOutput::CollabStopped => { + tracing::info!("CollabCoordinator: collab stopped"); + debug_state.with_mut(|ds| { + ds.is_joined = false; + ds.connected_peers = 0; + }); + } + + WorkerOutput::PeerConnected => { + tracing::info!("CollabCoordinator: peer connected, sending our Join"); + use weaver_api::sh_weaver::actor::ProfileDataViewInner; + + let fetcher = fetcher.clone(); + + // Get our profile info and send BroadcastJoin + let (our_did, our_display_name) = match fetcher.current_did().await { + Some(did) => { + let display_name = match fetcher.fetch_profile(&did.clone().into()).await { + Ok(profile) => { + match &profile.inner { + ProfileDataViewInner::ProfileView(p) => { + p.display_name.as_ref().map(|s| s.to_string()).unwrap_or_else(|| did.to_string()) + } + ProfileDataViewInner::ProfileViewDetailed(p) => { + p.display_name.as_ref().map(|s| s.to_string()).unwrap_or_else(|| did.to_string()) + } + ProfileDataViewInner::TangledProfileView(p) => { + p.handle.to_string() + } + _ => did.to_string(), + } + } + Err(_) => did.to_string(), + }; + (did.to_string(), display_name) + } + None => { + tracing::warn!("CollabCoordinator: no current DID for Join message"); + ("unknown".to_string(), "Anonymous".to_string()) + } + }; + + if let Some(ref mut s) = *worker_sink.write() { + if let Err(e) = s + .send(WorkerInput::BroadcastJoin { + did: our_did, + display_name: our_display_name, + }) + .await + { + tracing::error!("CollabCoordinator: BroadcastJoin send failed: {e}"); + } + } + } + + WorkerOutput::Error { message } => { + tracing::error!("CollabCoordinator: worker error: {message}"); + debug_state.with_mut(|ds| ds.last_error = Some(message.clone())); + state.set(CoordinatorState::Error(message)); + } + + WorkerOutput::Snapshot { .. } => {} + } + } + tracing::info!("CollabCoordinator: worker stream ended"); + }); + + tracing::info!("CollabCoordinator: spawned worker"); + }); + + // Forward cursor updates to worker - memo re-runs when cursor/selection signals change + let cursor_signal = props.document.cursor; + let selection_signal = props.document.selection; + + let _cursor_broadcaster = use_memo(move || { + let cursor = cursor_signal.read(); + let selection = *selection_signal.read(); + let position = cursor.offset; + let sel = selection.map(|s| (s.anchor, s.head)); + + tracing::debug!(position, ?sel, "CollabCoordinator: cursor changed, broadcasting"); + + spawn(async move { + if let Some(ref mut s) = *worker_sink.write() { + tracing::debug!(position, "CollabCoordinator: sending BroadcastCursor to worker"); + if let Err(e) = s + .send(WorkerInput::BroadcastCursor { + position, + selection: sel, + }) + .await + { + tracing::warn!("Failed to send BroadcastCursor to worker: {e}"); + } + } else { + tracing::debug!(position, "CollabCoordinator: worker sink not ready, skipping cursor broadcast"); + } + }); + }); + + // Periodic peer discovery + let fetcher_for_discovery = fetcher.clone(); + let resource_uri_for_discovery = resource_uri.clone(); + dioxus_sdk::time::use_interval( + std::time::Duration::from_millis(PEER_DISCOVERY_INTERVAL_MS as u64), + move |_| { + let fetcher = fetcher_for_discovery.clone(); + let resource_uri = resource_uri_for_discovery.clone(); + + spawn(async move { + let uri = match AtUri::new(&resource_uri) { + Ok(u) => u, + Err(_) => return, + }; + + match fetcher.find_session_peers(&uri).await { + Ok(peers) => { + debug_state.with_mut(|ds| ds.discovered_peers = peers.len()); + if !peers.is_empty() { + let peer_ids: Vec = + peers.into_iter().map(|p| p.node_id).collect(); + + if let Some(ref mut s) = *worker_sink.write() { + if let Err(e) = + s.send(WorkerInput::AddPeers { peers: peer_ids }).await + { + tracing::warn!("Periodic AddPeers send failed: {e}"); + } + } + } + } + Err(e) => { + tracing::debug!("Peer discovery failed: {e}"); + } + } + }); + }, + ); + + // Periodic session refresh + let fetcher_for_refresh = fetcher.clone(); + dioxus_sdk::time::use_interval( + std::time::Duration::from_millis(SESSION_REFRESH_INTERVAL_MS as u64), + move |_| { + let fetcher = fetcher_for_refresh.clone(); + + if let Some(ref uri) = *session_uri.peek() { + let uri = uri.clone(); + spawn(async move { + match fetcher + .refresh_collab_session(&uri, SESSION_TTL_MINUTES) + .await + { + Ok(_) => { + tracing::debug!("Session refreshed"); + } + Err(e) => { + tracing::warn!("Session refresh failed: {e}"); + } + } + }); + } + }, + ); + + // Cleanup on unmount + let fetcher_for_cleanup = fetcher.clone(); + use_drop(move || { + // Stop collab in worker + spawn(async move { + if let Some(ref mut s) = *worker_sink.write() { + if let Err(e) = s.send(WorkerInput::StopCollab).await { + tracing::warn!("Failed to send StopCollab to worker: {e}"); + } + } + }); + + // Delete session record + if let Some(uri) = session_uri.peek().clone() { + let fetcher = fetcher_for_cleanup.clone(); + spawn(async move { + if let Err(e) = fetcher.delete_collab_session(&uri).await { + tracing::warn!("Failed to delete session record: {e}"); + } + }); + } + }); + } + // Render children - this component is a wrapper that provides context + rsx! { {props.children} } +} diff --git a/crates/weaver-app/src/components/editor/component.rs b/crates/weaver-app/src/components/editor/component.rs index 4b74c36..4a22766 100644 --- a/crates/weaver-app/src/components/editor/component.rs +++ b/crates/weaver-app/src/components/editor/component.rs @@ -11,11 +11,6 @@ use jacquard::types::ident::AtIdentifier; use weaver_api::sh_weaver::embed::images::Image; use weaver_common::WeaverExt; -use crate::auth::AuthState; -use crate::components::collab::CollaboratorAvatars; -use crate::components::editor::ReportButton; -use crate::fetch::Fetcher; - use super::actions::{ EditorAction, Key, KeyCombo, KeybindingConfig, KeydownResult, Range, execute_action, handle_keydown_with_bindings, @@ -39,6 +34,11 @@ use super::sync::{SyncStatus, load_and_merge_document}; use super::toolbar::EditorToolbar; use super::visibility::update_syntax_visibility; use super::writer::{EditorImageResolver, SyntaxSpanInfo}; +use crate::auth::AuthState; +use crate::components::collab::CollaboratorAvatars; +use crate::components::editor::ReportButton; +use crate::components::editor::collab::CollabCoordinator; +use crate::fetch::Fetcher; /// Result of loading document state. enum LoadResult { @@ -425,41 +425,11 @@ fn MarkdownEditorInner( // Use pre-resolved content from loaded state (avoids embed pop-in) let resolved_content = use_signal(|| loaded_state.resolved_content.clone()); - // Presence tracker for remote collaborators (shared with RealTimeSync) - let presence = use_signal(weaver_common::transport::PresenceTracker::new); - - // Resolve StrongRef for real-time P2P sync - // - For entries: use entry_ref directly - // - For drafts: resolve via confirm_record_ref on draft URI - let realtime_ref_resource = { - let doc_for_ref = document.clone(); - let fetcher_for_ref = fetcher.clone(); - let draft_key_for_ref = draft_key.to_string(); - use_resource(move || { - let doc = doc_for_ref.clone(); - let fetcher = fetcher_for_ref.clone(); - let draft_key = draft_key_for_ref.clone(); - async move { - // Published entry - use entry_ref directly - if let Some(entry_ref) = doc.entry_ref() { - return Some(entry_ref.clone()); - } - // Draft with edit_root - resolve draft StrongRef - if doc.edit_root().is_some() { - if let Some(did) = fetcher.current_did().await { - let draft_uri = super::sync::build_draft_uri(&did, &draft_key); - match fetcher.confirm_record_ref(&draft_uri).await { - Ok(strong_ref) => return Some(strong_ref), - Err(e) => { - tracing::warn!("Failed to resolve draft ref: {}", e); - } - } - } - } - None - } - }) - }; + // Presence snapshot for remote collaborators (updated by collab coordinator) + let presence = use_signal(weaver_common::transport::PresenceSnapshot::default); + + // Resource URI for real-time collab (entry URI if editing published entry) + let collab_resource_uri = document.entry_ref().map(|r| r.uri.to_string()); let doc_for_memo = document.clone(); let doc_for_refs = document.clone(); @@ -516,9 +486,7 @@ fn MarkdownEditorInner( if !results.is_empty() { let mut rc = resolved_content_for_fetch.write_unchecked(); for (uri_str, html) in results { - if let Ok(at_uri) = - jacquard::types::string::AtUri::new_owned(uri_str) - { + if let Ok(at_uri) = jacquard::types::string::AtUri::new_owned(uri_str) { rc.add_embed(at_uri, html, None); } } @@ -724,27 +692,34 @@ fn MarkdownEditorInner( // Worker-based autosave (offloads export + encode to worker thread) #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] { - use super::worker::{EditorWorker, WorkerInput, WorkerOutput}; + use super::worker::{EditorReactor, WorkerInput, WorkerOutput}; use gloo_storage::Storage; use gloo_worker::Spawnable; + use gloo_worker::reactor::ReactorBridge; + + use futures_util::stream::{SplitSink, SplitStream}; // Track if worker is available (false = fallback to main thread) let use_worker: Signal = use_signal(|| true); - // Worker bridge handle - let mut worker_bridge: Signal>> = - use_signal(|| None); + // Worker sink for sending (split from bridge) + type WorkerSink = SplitSink, WorkerInput>; + let worker_sink: std::rc::Rc>> = + std::rc::Rc::new(std::cell::RefCell::new(None)); // Track version vector sent to worker (for incremental updates) let mut last_worker_vv: Signal> = use_signal(|| None); // Spawn worker on mount let doc_for_worker_init = document.clone(); let draft_key_for_worker = draft_key.clone(); + let worker_sink_for_spawn = worker_sink.clone(); + let mut presence_for_worker = presence; use_effect(move || { let doc = doc_for_worker_init.clone(); let draft_key = draft_key_for_worker.clone(); + let worker_sink = worker_sink_for_spawn.clone(); // Callback for worker responses - let on_output = move |output: WorkerOutput| { + let mut on_output = move |output: WorkerOutput| { match output { WorkerOutput::Ready => { tracing::info!("Editor worker ready"); @@ -781,30 +756,65 @@ fn MarkdownEditorInner( WorkerOutput::Error { message } => { tracing::error!("Worker error: {}", message); } + WorkerOutput::PresenceUpdate(snapshot) => { + tracing::debug!( + collaborators = snapshot.collaborators.len(), + peers = snapshot.peer_count, + "presence update from worker" + ); + presence_for_worker.set(snapshot); + } + // Ignore other collab outputs for now (handled by CollabCoordinator) + WorkerOutput::CollabReady { .. } + | WorkerOutput::CollabJoined + | WorkerOutput::RemoteUpdates { .. } + | WorkerOutput::CollabStopped + | WorkerOutput::PeerConnected => {} } }; - // Spawn worker (panics on failure in debug, returns bridge directly) - let bridge = EditorWorker::spawner() - .callback(on_output) - .spawn("/editor_worker.js"); + // Spawn reactor and split into sink/stream + use futures_util::StreamExt; + let bridge = EditorReactor::spawner().spawn("/editor_worker.js"); + let (sink, mut stream) = bridge.split(); + + // Store sink for sending + *worker_sink.borrow_mut() = Some(sink); // Initialize with current document snapshot let snapshot = doc.export_snapshot(); - bridge.send(WorkerInput::Init { - snapshot, - draft_key: draft_key.clone(), + let sink_for_init = worker_sink.clone(); + wasm_bindgen_futures::spawn_local(async move { + use futures_util::SinkExt; + if let Some(ref mut sink) = *sink_for_init.borrow_mut() { + let _ = sink + .send(WorkerInput::Init { + snapshot, + draft_key, + }) + .await; + } + }); + + // Spawn receiver task to poll stream for outputs + wasm_bindgen_futures::spawn_local(async move { + while let Some(msg) = stream.next().await { + on_output(msg); + } + tracing::info!("Editor reactor stream ended"); }); - worker_bridge.set(Some(bridge)); - tracing::info!("Editor worker spawned"); + + tracing::info!("Editor reactor spawned"); }); // Autosave interval let doc_for_autosave = document.clone(); let draft_key_for_autosave = draft_key.clone(); + let worker_sink_for_autosave = worker_sink.clone(); use_effect(move || { let mut doc = doc_for_autosave.clone(); let draft_key = draft_key_for_autosave.clone(); + let worker_sink = worker_sink_for_autosave.clone(); let interval = gloo_timers::callback::Interval::new(500, move || { let callback_start = crate::perf::now(); @@ -826,34 +836,46 @@ fn MarkdownEditorInner( doc.sync_loro_cursor(); // Try worker path first - if *use_worker.peek() { - if let Some(ref bridge) = *worker_bridge.peek() { - // Send updates to worker (or full snapshot if first time) - let current_vv = doc.version_vector(); - let updates = if let Some(ref last_vv) = *last_worker_vv.peek() { - doc.export_updates_from(last_vv).unwrap_or_default() - } else { - doc.export_snapshot() - }; + if *use_worker.peek() && worker_sink.borrow().is_some() { + // Send updates to worker (or full snapshot if first time) + let current_vv = doc.version_vector(); + let updates = if let Some(ref last_vv) = *last_worker_vv.peek() { + doc.export_updates_from(last_vv).unwrap_or_default() + } else { + doc.export_snapshot() + }; - if !updates.is_empty() { - bridge.send(WorkerInput::ApplyUpdates { updates }); - } + let cursor_offset = doc.cursor.read().offset; + let editing_uri = doc.entry_ref().map(|r| r.uri.to_string()); + let editing_cid = doc.entry_ref().map(|r| r.cid.to_string()); - // Request snapshot export - bridge.send(WorkerInput::ExportSnapshot { - cursor_offset: doc.cursor.read().offset, - editing_uri: doc.entry_ref().map(|r| r.uri.to_string()), - editing_cid: doc.entry_ref().map(|r| r.cid.to_string()), - }); + let sink_clone = worker_sink.clone(); - last_worker_vv.set(Some(current_vv)); - last_saved_frontiers.set(Some(current_frontiers)); + // Spawn async sends + wasm_bindgen_futures::spawn_local(async move { + use futures_util::SinkExt; + if let Some(ref mut sink) = *sink_clone.borrow_mut() { + if !updates.is_empty() { + let _ = sink.send(WorkerInput::ApplyUpdates { updates }).await; + } - let callback_ms = crate::perf::now() - callback_start; - tracing::debug!(callback_ms, "autosave via worker"); - return; - } + // Request snapshot export + let _ = sink + .send(WorkerInput::ExportSnapshot { + cursor_offset, + editing_uri, + editing_cid, + }) + .await; + } + }); + + last_worker_vv.set(Some(current_vv)); + last_saved_frontiers.set(Some(current_frontiers)); + + let callback_ms = crate::perf::now() - callback_start; + tracing::debug!(callback_ms, "autosave via worker"); + return; } // Fallback: main thread save @@ -997,281 +1019,295 @@ fn MarkdownEditorInner( rsx! { Stylesheet { href: asset!("/assets/styling/editor.css") } - div { class: "markdown-editor-container", - // Title bar - div { class: "editor-title-bar", - input { - r#type: "text", - class: "title-input", - placeholder: "Entry title...", - value: "{document.title()}", - oninput: { - let doc = document.clone(); - move |e| { - doc.set_title(&e.value()); - } - }, - } - } - - // Meta row - path, tags, publish - div { class: "editor-meta-row", - div { class: "meta-path", - label { "Path" } - input { - r#type: "text", - class: "path-input", - placeholder: "url-slug", - value: "{document.path()}", - oninput: { - let doc = document.clone(); - move |e| { - doc.set_path(&e.value()); - } - }, - } + CollabCoordinator { + document: document.clone(), + resource_uri: collab_resource_uri.clone().unwrap_or(draft_key.clone()), + presence, + div { class: "markdown-editor-container", + // Title bar + div { class: "editor-title-bar", + input { + r#type: "text", + class: "title-input", + placeholder: "Entry title...", + value: "{document.title()}", + oninput: { + let doc = document.clone(); + move |e| { + doc.set_title(&e.value()); + } + }, } + } - div { class: "meta-tags", - label { "Tags" } - div { class: "tags-container", - for tag in document.tags() { - span { - class: "tag-chip", - "{tag}" - button { - class: "tag-remove", - onclick: { - let doc = document.clone(); - let tag_to_remove = tag.clone(); - move |_| { - doc.remove_tag(&tag_to_remove); - } - }, - "×" - } - } - } + // Meta row - path, tags, publish + div { class: "editor-meta-row", + div { class: "meta-path", + label { "Path" } input { r#type: "text", - class: "tag-input", - placeholder: "Add tag...", - value: "{new_tag}", - oninput: move |e| new_tag.set(e.value()), - onkeydown: { + class: "path-input", + placeholder: "url-slug", + value: "{document.path()}", + oninput: { let doc = document.clone(); move |e| { - use dioxus::prelude::keyboard_types::Key; - if e.key() == Key::Enter && !new_tag().trim().is_empty() { - e.prevent_default(); - let tag = new_tag().trim().to_string(); - doc.add_tag(&tag); - new_tag.set(String::new()); - } + doc.set_path(&e.value()); } }, } } - } - div { class: "meta-actions", - // Show collaborator avatars when editing an existing entry - if let Some(entry_ref) = document.entry_ref() { - { - let title = document.title(); - rsx! { - CollaboratorAvatars { - resource_uri: entry_ref.uri.clone(), - resource_cid: entry_ref.cid.to_string(), - resource_title: if title.is_empty() { None } else { Some(title) }, + div { class: "meta-tags", + label { "Tags" } + div { class: "tags-container", + for tag in document.tags() { + span { + class: "tag-chip", + "{tag}" + button { + class: "tag-remove", + onclick: { + let doc = document.clone(); + let tag_to_remove = tag.clone(); + move |_| { + doc.remove_tag(&tag_to_remove); + } + }, + "×" + } } } + input { + r#type: "text", + class: "tag-input", + placeholder: "Add tag...", + value: "{new_tag}", + oninput: move |e| new_tag.set(e.value()), + onkeydown: { + let doc = document.clone(); + move |e| { + use dioxus::prelude::keyboard_types::Key; + if e.key() == Key::Enter && !new_tag().trim().is_empty() { + e.prevent_default(); + let tag = new_tag().trim().to_string(); + doc.add_tag(&tag); + new_tag.set(String::new()); + } + } + }, + } } } - { - // Enable collaborative sync for any published entry (both owners and collaborators) - let is_published = document.entry_ref().is_some(); - - // Refresh callback: fetch and merge collaborator changes (incremental) - let on_refresh = if is_published { - let fetcher_for_refresh = fetcher.clone(); - let mut doc_for_refresh = document.clone(); - let entry_uri = document.entry_ref().map(|r| r.uri.clone().into_static()); - - Some(EventHandler::new(move |_| { - let fetcher = fetcher_for_refresh.clone(); - let mut doc = doc_for_refresh.clone(); - let uri = entry_uri.clone(); - - spawn(async move { - if let Some(uri) = uri { - // Get last seen diffs for incremental sync - let last_seen = doc.last_seen_diffs.read().clone(); - - match super::sync::load_all_edit_states_from_pds(&fetcher, &uri, &last_seen).await { - Ok(Some(pds_state)) => { - if let Err(e) = doc.import_updates(&pds_state.root_snapshot) { - tracing::error!("Failed to import collaborator updates: {:?}", e); - } else { - tracing::info!("Successfully merged collaborator updates"); - // Update the last seen diffs for next incremental sync - *doc.last_seen_diffs.write() = pds_state.last_seen_diffs; + div { class: "meta-actions", + // Show collaborator avatars when editing an existing entry + if let Some(entry_ref) = document.entry_ref() { + { + let title = document.title(); + rsx! { + CollaboratorAvatars { + resource_uri: entry_ref.uri.clone(), + resource_cid: entry_ref.cid.to_string(), + resource_title: if title.is_empty() { None } else { Some(title) }, + } + } + } + } + + { + // Enable collaborative sync for any published entry (both owners and collaborators) + let is_published = document.entry_ref().is_some(); + + // Refresh callback: fetch and merge collaborator changes (incremental) + let on_refresh = if is_published { + let fetcher_for_refresh = fetcher.clone(); + let mut doc_for_refresh = document.clone(); + let entry_uri = document.entry_ref().map(|r| r.uri.clone().into_static()); + + Some(EventHandler::new(move |_| { + let fetcher = fetcher_for_refresh.clone(); + let mut doc = doc_for_refresh.clone(); + let uri = entry_uri.clone(); + + spawn(async move { + if let Some(uri) = uri { + // Get last seen diffs for incremental sync + let last_seen = doc.last_seen_diffs.read().clone(); + + match super::sync::load_all_edit_states_from_pds(&fetcher, &uri, &last_seen).await { + Ok(Some(pds_state)) => { + if let Err(e) = doc.import_updates(&pds_state.root_snapshot) { + tracing::error!("Failed to import collaborator updates: {:?}", e); + } else { + tracing::info!("Successfully merged collaborator updates"); + // Update the last seen diffs for next incremental sync + *doc.last_seen_diffs.write() = pds_state.last_seen_diffs; + } + } + Ok(None) => { + tracing::debug!("No collaborator updates found"); + } + Err(e) => { + tracing::error!("Failed to fetch collaborator updates: {}", e); } } - Ok(None) => { - tracing::debug!("No collaborator updates found"); - } - Err(e) => { - tracing::error!("Failed to fetch collaborator updates: {}", e); - } } - } - }); - })) - } else { - None - }; - - // Get resolved StrongRef for real-time P2P sync - let realtime_ref = realtime_ref_resource.read().clone().flatten(); + }); + })) + } else { + None + }; - rsx! { - SyncStatus { - document: document.clone(), - draft_key: draft_key.to_string(), - on_refresh, - is_collaborative: is_published, - } - // Real-time P2P sync (works for both published entries and drafts) - super::sync::RealTimeSync { - document: document.clone(), - resource_ref: realtime_ref, - presence, + rsx! { + SyncStatus { + document: document.clone(), + draft_key: draft_key.to_string(), + on_refresh, + is_collaborative: is_published, + } } } - } - PublishButton { - document: document.clone(), - draft_key: draft_key.to_string(), - target_notebook: target_notebook.as_ref().map(|s| s.to_string()), + PublishButton { + document: document.clone(), + draft_key: draft_key.to_string(), + target_notebook: target_notebook.as_ref().map(|s| s.to_string()), + } } } - } - // Editor content - div { class: "editor-content-wrapper", - // Remote collaborator cursors overlay - RemoteCursors { presence, document: document.clone(), render_cache } - div { - id: "{editor_id}", - class: "editor-content", - contenteditable: "true", - - onkeydown: { - let mut doc = document.clone(); - let keybindings = KeybindingConfig::default_for_platform(&platform::platform()); - move |evt| { - use dioxus::prelude::keyboard_types::Key; - use std::time::Duration; - - let plat = platform::platform(); - let mods = evt.modifiers(); - let has_modifier = mods.ctrl() || mods.meta() || mods.alt(); - - // During IME composition: - // - Allow modifier shortcuts (Ctrl+B, Ctrl+Z, etc.) - // - Allow Escape to cancel composition - // - Block text input (let browser handle composition preview) - if doc.composition.read().is_some() { - if evt.key() == Key::Escape { - tracing::debug!("Escape pressed - cancelling composition"); - doc.composition.set(None); - return; - } - - // Allow modifier shortcuts through during composition - if !has_modifier { - tracing::debug!( - key = ?evt.key(), - "keydown during composition - delegating to browser" - ); - return; - } - // Fall through to handle the shortcut - } + // Editor content + div { class: "editor-content-wrapper", + // Remote collaborator cursors overlay + RemoteCursors { presence, document: document.clone(), render_cache } + div { + id: "{editor_id}", + class: "editor-content", + contenteditable: "true", + + onkeydown: { + let mut doc = document.clone(); + let keybindings = KeybindingConfig::default_for_platform(&platform::platform()); + move |evt| { + use dioxus::prelude::keyboard_types::Key; + use std::time::Duration; + + let plat = platform::platform(); + let mods = evt.modifiers(); + let has_modifier = mods.ctrl() || mods.meta() || mods.alt(); + + // During IME composition: + // - Allow modifier shortcuts (Ctrl+B, Ctrl+Z, etc.) + // - Allow Escape to cancel composition + // - Block text input (let browser handle composition preview) + if doc.composition.read().is_some() { + if evt.key() == Key::Escape { + tracing::debug!("Escape pressed - cancelling composition"); + doc.composition.set(None); + return; + } - // Safari workaround: After Japanese IME composition ends, both - // compositionend and keydown fire for Enter. Ignore keydown - // within 500ms of composition end to prevent double-newline. - if plat.safari && evt.key() == Key::Enter { - if let Some(ended_at) = *doc.composition_ended_at.read() { - if ended_at.elapsed() < Duration::from_millis(500) { + // Allow modifier shortcuts through during composition + if !has_modifier { tracing::debug!( - "Safari: ignoring Enter within 500ms of compositionend" + key = ?evt.key(), + "keydown during composition - delegating to browser" ); return; } + // Fall through to handle the shortcut } - } - // Try keybindings first (for shortcuts like Ctrl+B, Ctrl+Z, etc.) - let combo = KeyCombo::from_keyboard_event(&evt.data()); - let cursor_offset = doc.cursor.read().offset; - let selection = *doc.selection.read(); - let range = selection - .map(|s| Range::new(s.anchor.min(s.head), s.anchor.max(s.head))) - .unwrap_or_else(|| Range::caret(cursor_offset)); - match handle_keydown_with_bindings(&mut doc, &keybindings, combo, range) { - KeydownResult::Handled => { - evt.prevent_default(); - return; - } - KeydownResult::PassThrough => { - // Navigation keys - let browser handle, sync in keyup - return; + // Safari workaround: After Japanese IME composition ends, both + // compositionend and keydown fire for Enter. Ignore keydown + // within 500ms of composition end to prevent double-newline. + if plat.safari && evt.key() == Key::Enter { + if let Some(ended_at) = *doc.composition_ended_at.read() { + if ended_at.elapsed() < Duration::from_millis(500) { + tracing::debug!( + "Safari: ignoring Enter within 500ms of compositionend" + ); + return; + } + } } - KeydownResult::NotHandled => { - // Text input - let beforeinput handle it + + // Try keybindings first (for shortcuts like Ctrl+B, Ctrl+Z, etc.) + let combo = KeyCombo::from_keyboard_event(&evt.data()); + let cursor_offset = doc.cursor.read().offset; + let selection = *doc.selection.read(); + let range = selection + .map(|s| Range::new(s.anchor.min(s.head), s.anchor.max(s.head))) + .unwrap_or_else(|| Range::caret(cursor_offset)); + match handle_keydown_with_bindings(&mut doc, &keybindings, combo, range) { + KeydownResult::Handled => { + evt.prevent_default(); + return; + } + KeydownResult::PassThrough => { + // Navigation keys - let browser handle, sync in keyup + return; + } + KeydownResult::NotHandled => { + // Text input - let beforeinput handle it + } } + + // Text input keys: let beforeinput handle them + // We don't prevent default here - beforeinput will do that } + }, - // Text input keys: let beforeinput handle them - // We don't prevent default here - beforeinput will do that - } - }, + onkeyup: { + let mut doc = document.clone(); + move |evt| { + use dioxus::prelude::keyboard_types::Key; - onkeyup: { - let mut doc = document.clone(); - move |evt| { - use dioxus::prelude::keyboard_types::Key; - - // Arrow keys with direction hint for snapping - let direction_hint = match evt.key() { - Key::ArrowLeft | Key::ArrowUp => Some(SnapDirection::Backward), - Key::ArrowRight | Key::ArrowDown => Some(SnapDirection::Forward), - _ => None, - }; + // Arrow keys with direction hint for snapping + let direction_hint = match evt.key() { + Key::ArrowLeft | Key::ArrowUp => Some(SnapDirection::Backward), + Key::ArrowRight | Key::ArrowDown => Some(SnapDirection::Forward), + _ => None, + }; - // Navigation keys (with or without Shift for selection) - let navigation = matches!( - evt.key(), - Key::ArrowLeft | Key::ArrowRight | Key::ArrowUp | Key::ArrowDown | - Key::Home | Key::End | Key::PageUp | Key::PageDown - ); + // Navigation keys (with or without Shift for selection) + let navigation = matches!( + evt.key(), + Key::ArrowLeft | Key::ArrowRight | Key::ArrowUp | Key::ArrowDown | + Key::Home | Key::End | Key::PageUp | Key::PageDown + ); - // Cmd/Ctrl+A for select all - let select_all = (evt.modifiers().meta() || evt.modifiers().ctrl()) - && matches!(evt.key(), Key::Character(ref c) if c == "a"); + // Cmd/Ctrl+A for select all + let select_all = (evt.modifiers().meta() || evt.modifiers().ctrl()) + && matches!(evt.key(), Key::Character(ref c) if c == "a"); - if navigation || select_all { - let paras = cached_paragraphs(); - if let Some(dir) = direction_hint { - sync_cursor_from_dom_with_direction(&mut doc, editor_id, ¶s, Some(dir)); - } else { - sync_cursor_from_dom(&mut doc, editor_id, ¶s); + if navigation || select_all { + let paras = cached_paragraphs(); + if let Some(dir) = direction_hint { + sync_cursor_from_dom_with_direction(&mut doc, editor_id, ¶s, Some(dir)); + } else { + sync_cursor_from_dom(&mut doc, editor_id, ¶s); + } + let spans = syntax_spans(); + let cursor_offset = doc.cursor.read().offset; + let selection = *doc.selection.read(); + update_syntax_visibility( + cursor_offset, + selection.as_ref(), + &spans, + ¶s, + ); } + } + }, + + onselect: { + let mut doc = document.clone(); + move |_evt| { + tracing::trace!("onselect fired"); + let paras = cached_paragraphs(); + sync_cursor_from_dom(&mut doc, editor_id, ¶s); let spans = syntax_spans(); let cursor_offset = doc.cursor.read().offset; let selection = *doc.selection.read(); @@ -1282,448 +1318,430 @@ fn MarkdownEditorInner( ¶s, ); } - } - }, - - onselect: { - let mut doc = document.clone(); - move |_evt| { - tracing::trace!("onselect fired"); - let paras = cached_paragraphs(); - sync_cursor_from_dom(&mut doc, editor_id, ¶s); - let spans = syntax_spans(); - let cursor_offset = doc.cursor.read().offset; - let selection = *doc.selection.read(); - update_syntax_visibility( - cursor_offset, - selection.as_ref(), - &spans, - ¶s, - ); - } - }, + }, - onselectstart: { - let mut doc = document.clone(); - move |_evt| { - tracing::trace!("onselectstart fired"); - let paras = cached_paragraphs(); - sync_cursor_from_dom(&mut doc, editor_id, ¶s); - let spans = syntax_spans(); - let cursor_offset = doc.cursor.read().offset; - let selection = *doc.selection.read(); - update_syntax_visibility( - cursor_offset, - selection.as_ref(), - &spans, - ¶s, - ); - } - }, + onselectstart: { + let mut doc = document.clone(); + move |_evt| { + tracing::trace!("onselectstart fired"); + let paras = cached_paragraphs(); + sync_cursor_from_dom(&mut doc, editor_id, ¶s); + let spans = syntax_spans(); + let cursor_offset = doc.cursor.read().offset; + let selection = *doc.selection.read(); + update_syntax_visibility( + cursor_offset, + selection.as_ref(), + &spans, + ¶s, + ); + } + }, - onselectionchange: { - let mut doc = document.clone(); - move |_evt| { - tracing::trace!("onselectionchange fired"); - let paras = cached_paragraphs(); - sync_cursor_from_dom(&mut doc, editor_id, ¶s); - let spans = syntax_spans(); - let cursor_offset = doc.cursor.read().offset; - let selection = *doc.selection.read(); - update_syntax_visibility( - cursor_offset, - selection.as_ref(), - &spans, - ¶s, - ); - } - }, + onselectionchange: { + let mut doc = document.clone(); + move |_evt| { + tracing::trace!("onselectionchange fired"); + let paras = cached_paragraphs(); + sync_cursor_from_dom(&mut doc, editor_id, ¶s); + let spans = syntax_spans(); + let cursor_offset = doc.cursor.read().offset; + let selection = *doc.selection.read(); + update_syntax_visibility( + cursor_offset, + selection.as_ref(), + &spans, + ¶s, + ); + } + }, - onclick: { - let mut doc = document.clone(); - move |evt| { - tracing::trace!("onclick fired"); - let paras = cached_paragraphs(); + onclick: { + let mut doc = document.clone(); + move |evt| { + tracing::trace!("onclick fired"); + let paras = cached_paragraphs(); - // Check if click target is a math-clickable element - #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] - { - use dioxus::web::WebEventExt; - use wasm_bindgen::JsCast; - - let web_evt = evt.as_web_event(); - if let Some(target) = web_evt.target() { - if let Some(element) = target.dyn_ref::() { - // Check element or ancestors for math-clickable - if let Ok(Some(math_el)) = element.closest(".math-clickable") { - if let Some(char_target) = math_el.get_attribute("data-char-target") { - if let Ok(offset) = char_target.parse::() { - tracing::debug!("math-clickable clicked, moving cursor to {}", offset); - doc.cursor.write().offset = offset; - *doc.selection.write() = None; - // Update visibility FIRST so math-source is visible - let spans = syntax_spans(); - update_syntax_visibility(offset, None, &spans, ¶s); - // Then set DOM selection - let map = offset_map(); - let _ = crate::components::editor::cursor::restore_cursor_position( - offset, - &map, - editor_id, - None, - ); - return; + // Check if click target is a math-clickable element + #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] + { + use dioxus::web::WebEventExt; + use wasm_bindgen::JsCast; + + let web_evt = evt.as_web_event(); + if let Some(target) = web_evt.target() { + if let Some(element) = target.dyn_ref::() { + // Check element or ancestors for math-clickable + if let Ok(Some(math_el)) = element.closest(".math-clickable") { + if let Some(char_target) = math_el.get_attribute("data-char-target") { + if let Ok(offset) = char_target.parse::() { + tracing::debug!("math-clickable clicked, moving cursor to {}", offset); + doc.cursor.write().offset = offset; + *doc.selection.write() = None; + // Update visibility FIRST so math-source is visible + let spans = syntax_spans(); + update_syntax_visibility(offset, None, &spans, ¶s); + // Then set DOM selection + let map = offset_map(); + let _ = crate::components::editor::cursor::restore_cursor_position( + offset, + &map, + editor_id, + None, + ); + return; + } } } } } } - } - sync_cursor_from_dom(&mut doc, editor_id, ¶s); - let spans = syntax_spans(); - let cursor_offset = doc.cursor.read().offset; - let selection = *doc.selection.read(); - update_syntax_visibility( - cursor_offset, - selection.as_ref(), - &spans, - ¶s, - ); - } - }, - - // Android workaround: Handle Enter in keypress instead of keydown. - // Chrome Android fires confused composition events on Enter in keydown, - // but keypress fires after composition state settles. - onkeypress: { - let mut doc = document.clone(); - move |evt| { - use dioxus::prelude::keyboard_types::Key; + sync_cursor_from_dom(&mut doc, editor_id, ¶s); + let spans = syntax_spans(); + let cursor_offset = doc.cursor.read().offset; + let selection = *doc.selection.read(); + update_syntax_visibility( + cursor_offset, + selection.as_ref(), + &spans, + ¶s, + ); + } + }, - let plat = platform::platform(); - if plat.android && evt.key() == Key::Enter { - tracing::debug!("Android: handling Enter in keypress"); - evt.prevent_default(); + // Android workaround: Handle Enter in keypress instead of keydown. + // Chrome Android fires confused composition events on Enter in keydown, + // but keypress fires after composition state settles. + onkeypress: { + let mut doc = document.clone(); + move |evt| { + use dioxus::prelude::keyboard_types::Key; + + let plat = platform::platform(); + if plat.android && evt.key() == Key::Enter { + tracing::debug!("Android: handling Enter in keypress"); + evt.prevent_default(); - // Get current range - let range = if let Some(sel) = *doc.selection.read() { - Range::new(sel.anchor.min(sel.head), sel.anchor.max(sel.head)) - } else { - Range::caret(doc.cursor.read().offset) - }; + // Get current range + let range = if let Some(sel) = *doc.selection.read() { + Range::new(sel.anchor.min(sel.head), sel.anchor.max(sel.head)) + } else { + Range::caret(doc.cursor.read().offset) + }; - let action = EditorAction::InsertParagraph { range }; - execute_action(&mut doc, &action); + let action = EditorAction::InsertParagraph { range }; + execute_action(&mut doc, &action); + } } - } - }, + }, - onpaste: { - let mut doc = document.clone(); - move |evt| { - handle_paste(evt, &mut doc); - } - }, + onpaste: { + let mut doc = document.clone(); + move |evt| { + handle_paste(evt, &mut doc); + } + }, - oncut: { - let mut doc = document.clone(); - move |evt| { - handle_cut(evt, &mut doc); - } - }, + oncut: { + let mut doc = document.clone(); + move |evt| { + handle_cut(evt, &mut doc); + } + }, - oncopy: { - let doc = document.clone(); - move |evt| { - handle_copy(evt, &doc); - } - }, + oncopy: { + let doc = document.clone(); + move |evt| { + handle_copy(evt, &doc); + } + }, - onblur: { - let mut doc = document.clone(); - move |_| { - // Cancel any in-progress IME composition on focus loss - let had_composition = doc.composition.read().is_some(); - if had_composition { - tracing::debug!("onblur: clearing active composition"); + onblur: { + let mut doc = document.clone(); + move |_| { + // Cancel any in-progress IME composition on focus loss + let had_composition = doc.composition.read().is_some(); + if had_composition { + tracing::debug!("onblur: clearing active composition"); + } + doc.composition.set(None); } - doc.composition.set(None); - } - }, + }, - oncompositionstart: { - let mut doc = document.clone(); - move |evt: CompositionEvent| { - let data = evt.data().data(); - tracing::trace!( - data = %data, - "compositionstart" - ); - // Delete selection if present (composition replaces it) - let sel = doc.selection.write().take(); - if let Some(sel) = sel { - let (start, end) = - (sel.anchor.min(sel.head), sel.anchor.max(sel.head)); + oncompositionstart: { + let mut doc = document.clone(); + move |evt: CompositionEvent| { + let data = evt.data().data(); tracing::trace!( - start, - end, - "compositionstart: deleting selection" + data = %data, + "compositionstart" ); - let _ = doc.remove_tracked(start, end.saturating_sub(start)); - doc.cursor.write().offset = start; - } + // Delete selection if present (composition replaces it) + let sel = doc.selection.write().take(); + if let Some(sel) = sel { + let (start, end) = + (sel.anchor.min(sel.head), sel.anchor.max(sel.head)); + tracing::trace!( + start, + end, + "compositionstart: deleting selection" + ); + let _ = doc.remove_tracked(start, end.saturating_sub(start)); + doc.cursor.write().offset = start; + } - let cursor_offset = doc.cursor.read().offset; - tracing::trace!( - cursor = cursor_offset, - "compositionstart: setting composition state" - ); - doc.composition.set(Some(CompositionState { - start_offset: cursor_offset, - text: data, - })); - } - }, + let cursor_offset = doc.cursor.read().offset; + tracing::trace!( + cursor = cursor_offset, + "compositionstart: setting composition state" + ); + doc.composition.set(Some(CompositionState { + start_offset: cursor_offset, + text: data, + })); + } + }, - oncompositionupdate: { - let mut doc = document.clone(); - move |evt: CompositionEvent| { - let data = evt.data().data(); - tracing::trace!( - data = %data, - "compositionupdate" - ); - let mut comp_guard = doc.composition.write(); - if let Some(ref mut comp) = *comp_guard { - comp.text = data; - } else { - tracing::debug!("compositionupdate without active composition state"); + oncompositionupdate: { + let mut doc = document.clone(); + move |evt: CompositionEvent| { + let data = evt.data().data(); + tracing::trace!( + data = %data, + "compositionupdate" + ); + let mut comp_guard = doc.composition.write(); + if let Some(ref mut comp) = *comp_guard { + comp.text = data; + } else { + tracing::debug!("compositionupdate without active composition state"); + } } - } - }, + }, - oncompositionend: { - let mut doc = document.clone(); - move |evt: CompositionEvent| { - let final_text = evt.data().data(); - tracing::trace!( - data = %final_text, - "compositionend" - ); - // Record when composition ended for Safari timing workaround - doc.composition_ended_at.set(Some(web_time::Instant::now())); - - let comp = doc.composition.write().take(); - if let Some(comp) = comp { - tracing::debug!( - start_offset = comp.start_offset, - final_text = %final_text, - chars = final_text.chars().count(), - "compositionend: inserting text" + oncompositionend: { + let mut doc = document.clone(); + move |evt: CompositionEvent| { + let final_text = evt.data().data(); + tracing::trace!( + data = %final_text, + "compositionend" ); + // Record when composition ended for Safari timing workaround + doc.composition_ended_at.set(Some(web_time::Instant::now())); - if !final_text.is_empty() { - let mut delete_start = comp.start_offset; - while delete_start > 0 { - match get_char_at(doc.loro_text(), delete_start - 1) { - Some('\u{200C}') | Some('\u{200B}') => delete_start -= 1, - _ => break, + let comp = doc.composition.write().take(); + if let Some(comp) = comp { + tracing::debug!( + start_offset = comp.start_offset, + final_text = %final_text, + chars = final_text.chars().count(), + "compositionend: inserting text" + ); + + if !final_text.is_empty() { + let mut delete_start = comp.start_offset; + while delete_start > 0 { + match get_char_at(doc.loro_text(), delete_start - 1) { + Some('\u{200C}') | Some('\u{200B}') => delete_start -= 1, + _ => break, + } } - } - let cursor_offset = doc.cursor.read().offset; - let zw_count = cursor_offset - delete_start; - if zw_count > 0 { - // Splice: delete zero-width chars and insert new char in one op - let _ = doc.replace_tracked(delete_start, zw_count, &final_text); - doc.cursor.write().offset = delete_start + final_text.chars().count(); - } else if cursor_offset == doc.len_chars() { - // Fast path: append at end - let _ = doc.push_tracked(&final_text); - doc.cursor.write().offset = comp.start_offset + final_text.chars().count(); - } else { - let _ = doc.insert_tracked(cursor_offset, &final_text); - doc.cursor.write().offset = comp.start_offset + final_text.chars().count(); + let cursor_offset = doc.cursor.read().offset; + let zw_count = cursor_offset - delete_start; + if zw_count > 0 { + // Splice: delete zero-width chars and insert new char in one op + let _ = doc.replace_tracked(delete_start, zw_count, &final_text); + doc.cursor.write().offset = delete_start + final_text.chars().count(); + } else if cursor_offset == doc.len_chars() { + // Fast path: append at end + let _ = doc.push_tracked(&final_text); + doc.cursor.write().offset = comp.start_offset + final_text.chars().count(); + } else { + let _ = doc.insert_tracked(cursor_offset, &final_text); + doc.cursor.write().offset = comp.start_offset + final_text.chars().count(); + } } + } else { + tracing::debug!("compositionend without active composition state"); } - } else { - tracing::debug!("compositionend without active composition state"); } + }, } - }, - } - div { class: "editor-debug", - div { "Cursor: {document.cursor.read().offset}, Chars: {document.len_chars()}" }, - // Collab debug info - { - if let Some(debug_state) = crate::collab_context::try_use_collab_debug() { - let ds = debug_state.read(); - rsx! { - div { class: "collab-debug", - if let Some(ref node_id) = ds.node_id { - span { title: "{node_id}", "Node: {&node_id[..8.min(node_id.len())]}…" } - } - if ds.is_joined { - span { class: "joined", "✓ Joined" } - } - span { "Peers: {ds.discovered_peers}" } - if let Some(ref err) = ds.last_error { - span { class: "error", title: "{err}", "âš " } + div { class: "editor-debug", + div { "Cursor: {document.cursor.read().offset}, Chars: {document.len_chars()}" }, + // Collab debug info + { + if let Some(debug_state) = crate::collab_context::try_use_collab_debug() { + let ds = debug_state.read(); + rsx! { + div { class: "collab-debug", + if let Some(ref node_id) = ds.node_id { + span { title: "{node_id}", "Node: {&node_id[..8.min(node_id.len())]}…" } + } + if ds.is_joined { + span { class: "joined", "✓ Joined" } + } + span { "Peers: {ds.discovered_peers}" } + if let Some(ref err) = ds.last_error { + span { class: "error", title: "{err}", "âš " } + } } } + } else { + rsx! {} } - } else { - rsx! {} + }, + ReportButton { + email: "editor-bugs@weaver.sh".to_string(), + editor_id: "markdown-editor".to_string(), } - }, - ReportButton { - email: "editor-bugs@weaver.sh".to_string(), - editor_id: "markdown-editor".to_string(), } } - } - EditorToolbar { - on_format: { - let mut doc = document.clone(); - move |action| { - formatting::apply_formatting(&mut doc, action); - } - }, - on_image: { - let mut doc = document.clone(); - move |uploaded: super::image_upload::UploadedImage| { - // Build data URL for immediate preview - use base64::{Engine, engine::general_purpose::STANDARD}; - let data_url = format!( - "data:{};base64,{}", - uploaded.mime_type, - STANDARD.encode(&uploaded.data) - ); + EditorToolbar { + on_format: { + let mut doc = document.clone(); + move |action| { + formatting::apply_formatting(&mut doc, action); + } + }, + on_image: { + let mut doc = document.clone(); + move |uploaded: super::image_upload::UploadedImage| { + // Build data URL for immediate preview + use base64::{Engine, engine::general_purpose::STANDARD}; + let data_url = format!( + "data:{};base64,{}", + uploaded.mime_type, + STANDARD.encode(&uploaded.data) + ); - // Add to resolver for immediate display - let name = uploaded.name.clone(); - image_resolver.with_mut(|resolver| { - resolver.add_pending(name.clone(), data_url); - }); + // Add to resolver for immediate display + let name = uploaded.name.clone(); + image_resolver.with_mut(|resolver| { + resolver.add_pending(name.clone(), data_url); + }); - // Insert markdown image syntax at cursor - let alt_text = if uploaded.alt.is_empty() { - name.clone() - } else { - uploaded.alt.clone() - }; + // Insert markdown image syntax at cursor + let alt_text = if uploaded.alt.is_empty() { + name.clone() + } else { + uploaded.alt.clone() + }; - // Check if authenticated and get DID for draft path - let auth = auth_state.read(); - let did_for_path = auth.did.clone(); - let is_authenticated = auth.is_authenticated(); - drop(auth); - - // Pre-generate TID for the blob rkey (used in draft path and upload) - let blob_tid = jacquard::types::tid::Ticker::new().next(None); - - // Build markdown with proper draft path if authenticated - let markdown = if let Some(ref did) = did_for_path { - format!("![{}](/image/{}/draft/{}/{})", alt_text, did, blob_tid.as_str(), name) - } else { - // Fallback for unauthenticated - simple path (won't be publishable anyway) - format!("![{}](/image/{})", alt_text, name) - }; + // Check if authenticated and get DID for draft path + let auth = auth_state.read(); + let did_for_path = auth.did.clone(); + let is_authenticated = auth.is_authenticated(); + drop(auth); - let pos = doc.cursor.read().offset; - let _ = doc.insert_tracked(pos, &markdown); - doc.cursor.write().offset = pos + markdown.chars().count(); - - // Upload to PDS in background if authenticated - if is_authenticated { - let fetcher = fetcher.clone(); - let name_for_upload = name.clone(); - let alt_for_upload = alt_text.clone(); - let data = uploaded.data.clone(); - let mut doc_for_spawn = doc.clone(); - - spawn(async move { - let client = fetcher.get_client(); - - // Clone data for cache pre-warming - let data_for_cache = data.clone(); - - // Use pre-generated TID as rkey for the blob record - let rkey = jacquard::types::recordkey::RecordKey::any(blob_tid.as_str()) - .expect("TID is valid record key"); - - // Upload blob and create temporary PublishedBlob record - match client.publish_blob(data, &name_for_upload, Some(rkey)).await { - Ok((strong_ref, published_blob)) => { - // Get DID from fetcher - let did = match fetcher.current_did().await { - Some(d) => d, - None => { - tracing::warn!("No DID available"); - return; - } - }; + // Pre-generate TID for the blob rkey (used in draft path and upload) + let blob_tid = jacquard::types::tid::Ticker::new().next(None); - // Extract rkey from the AT-URI - let blob_rkey = match strong_ref.uri.rkey() { - Some(rkey) => rkey.0.clone().into_static(), - None => { - tracing::warn!("No rkey in PublishedBlob URI"); - return; - } - }; + // Build markdown with proper draft path if authenticated + let markdown = if let Some(ref did) = did_for_path { + format!("![{}](/image/{}/draft/{}/{})", alt_text, did, blob_tid.as_str(), name) + } else { + // Fallback for unauthenticated - simple path (won't be publishable anyway) + format!("![{}](/image/{})", alt_text, name) + }; - let cid = published_blob.upload.blob().cid().clone().into_static(); - - let name_for_resolver = name_for_upload.clone(); - let image = Image::new() - .alt(alt_for_upload.to_cowstr()) - .image(published_blob.upload) - .name(name_for_upload.to_cowstr()) - .build(); - doc_for_spawn.add_image(&image, Some(&strong_ref.uri)); - - // Promote from pending to uploaded in resolver - let ident = AtIdentifier::Did(did); - image_resolver.with_mut(|resolver| { - resolver.promote_to_uploaded( - &name_for_resolver, - blob_rkey, - ident, - ); - }); + let pos = doc.cursor.read().offset; + let _ = doc.insert_tracked(pos, &markdown); + doc.cursor.write().offset = pos + markdown.chars().count(); + + // Upload to PDS in background if authenticated + if is_authenticated { + let fetcher = fetcher.clone(); + let name_for_upload = name.clone(); + let alt_for_upload = alt_text.clone(); + let data = uploaded.data.clone(); + let mut doc_for_spawn = doc.clone(); + + spawn(async move { + let client = fetcher.get_client(); + + // Clone data for cache pre-warming + let data_for_cache = data.clone(); + + // Use pre-generated TID as rkey for the blob record + let rkey = jacquard::types::recordkey::RecordKey::any(blob_tid.as_str()) + .expect("TID is valid record key"); + + // Upload blob and create temporary PublishedBlob record + match client.publish_blob(data, &name_for_upload, Some(rkey)).await { + Ok((strong_ref, published_blob)) => { + // Get DID from fetcher + let did = match fetcher.current_did().await { + Some(d) => d, + None => { + tracing::warn!("No DID available"); + return; + } + }; + + // Extract rkey from the AT-URI + let blob_rkey = match strong_ref.uri.rkey() { + Some(rkey) => rkey.0.clone().into_static(), + None => { + tracing::warn!("No rkey in PublishedBlob URI"); + return; + } + }; + + let cid = published_blob.upload.blob().cid().clone().into_static(); + + let name_for_resolver = name_for_upload.clone(); + let image = Image::new() + .alt(alt_for_upload.to_cowstr()) + .image(published_blob.upload) + .name(name_for_upload.to_cowstr()) + .build(); + doc_for_spawn.add_image(&image, Some(&strong_ref.uri)); + + // Promote from pending to uploaded in resolver + let ident = AtIdentifier::Did(did); + image_resolver.with_mut(|resolver| { + resolver.promote_to_uploaded( + &name_for_resolver, + blob_rkey, + ident, + ); + }); - tracing::info!(name = %name_for_resolver, "Image uploaded to PDS"); - - // Pre-warm server cache with blob bytes - #[cfg(feature = "fullstack-server")] - { - use jacquard::smol_str::ToSmolStr; - if let Err(e) = crate::data::cache_blob_bytes( - cid.to_smolstr(), - Some(name_for_resolver.into()), - None, - data_for_cache.into(), - ).await { - tracing::warn!(error = %e, "Failed to pre-warm blob cache"); + tracing::info!(name = %name_for_resolver, "Image uploaded to PDS"); + + // Pre-warm server cache with blob bytes + #[cfg(feature = "fullstack-server")] + { + use jacquard::smol_str::ToSmolStr; + if let Err(e) = crate::data::cache_blob_bytes( + cid.to_smolstr(), + Some(name_for_resolver.into()), + None, + data_for_cache.into(), + ).await { + tracing::warn!(error = %e, "Failed to pre-warm blob cache"); + } } } + Err(e) => { + tracing::error!(error = %e, "Failed to upload image"); + // Image stays as data URL - will work for preview but not publish + } } - Err(e) => { - tracing::error!(error = %e, "Failed to upload image"); - // Image stays as data URL - will work for preview but not publish - } - } - }); - } else { - tracing::debug!(name = %name, "Image added with data URL (not authenticated)"); + }); + } else { + tracing::debug!(name = %name, "Image added with data URL (not authenticated)"); + } } - } - }, - } + }, + } + } } } } @@ -1734,15 +1752,19 @@ fn MarkdownEditorInner( /// Uses the same offset mapping as local cursor restoration. #[component] fn RemoteCursors( - presence: Signal, + presence: Signal, document: EditorDocument, render_cache: Signal, ) -> Element { let presence_read = presence.read(); - let cursor_count = presence_read.len(); + let cursor_count = presence_read.collaborators.len(); let cursors: Vec<_> = presence_read - .cursors() - .map(|(c, cur)| (c.display_name.clone(), c.color, cur.position, cur.selection)) + .collaborators + .iter() + .filter_map(|c| { + c.cursor_position + .map(|pos| (c.display_name.clone(), c.color, pos, c.selection)) + }) .collect(); if cursor_count > 0 { diff --git a/crates/weaver-app/src/components/editor/mod.rs b/crates/weaver-app/src/components/editor/mod.rs index f2e5937..4a78ce9 100644 --- a/crates/weaver-app/src/components/editor/mod.rs +++ b/crates/weaver-app/src/components/editor/mod.rs @@ -6,6 +6,7 @@ mod actions; mod beforeinput; +mod collab; mod component; mod cursor; mod document; @@ -36,7 +37,9 @@ pub use component::MarkdownEditor; // Document types #[allow(unused_imports)] -pub use document::{Affinity, CompositionState, CursorState, EditorDocument, LoadedDocState, Selection}; +pub use document::{ + Affinity, CompositionState, CursorState, EditorDocument, LoadedDocState, Selection, +}; // Formatting #[allow(unused_imports)] @@ -62,9 +65,8 @@ pub use storage::{ // Sync #[allow(unused_imports)] pub use sync::{ + PdsEditState, RemoteDraft, SyncState, SyncStatus, list_drafts_from_pds, load_and_merge_document, load_edit_state_from_pds, sync_to_pds, - list_drafts_from_pds, RemoteDraft, - PdsEditState, SyncState, SyncStatus, }; // UI components @@ -85,5 +87,9 @@ pub use log_buffer::LogCaptureLayer; // Worker #[cfg(all(target_family = "wasm", target_os = "unknown"))] pub use worker::{ - EditorWorker, EmbedWorker, EmbedWorkerInput, EmbedWorkerOutput, WorkerInput, WorkerOutput, + EditorReactor, EmbedWorker, EmbedWorkerInput, EmbedWorkerOutput, WorkerInput, WorkerOutput, }; + +// Collab coordinator +#[cfg(all(target_arch = "wasm32", target_os = "unknown"))] +pub use collab::CollabCoordinator; diff --git a/crates/weaver-app/src/components/editor/sync.rs b/crates/weaver-app/src/components/editor/sync.rs index c47ca56..44e3975 100644 --- a/crates/weaver-app/src/components/editor/sync.rs +++ b/crates/weaver-app/src/components/editor/sync.rs @@ -1343,573 +1343,6 @@ pub async fn load_and_merge_document( } } -// ============================================================================ -// Real-Time P2P Sync (iroh-gossip) -// ============================================================================ - -#[cfg(all(target_arch = "wasm32", target_os = "unknown"))] -use crate::collab_context::use_collab_node; -#[cfg(all(target_arch = "wasm32", target_os = "unknown"))] -use std::sync::Arc; - -use weaver_common::transport::PresenceTracker; - -/// Props for real-time P2P sync component. -#[derive(Props, Clone, PartialEq)] -pub struct RealTimeSyncProps { - /// The editor document to sync - pub document: super::document::EditorDocument, - /// StrongRef to the resource being edited (for topic derivation and session records) - pub resource_ref: Option>, - /// Presence tracker for remote collaborators (shared with editor for rendering) - pub presence: Signal, -} - -/// Real-time P2P sync component using iroh-gossip. -/// -/// When editing a collaborative document, this component: -/// - Joins a gossip topic for the resource -/// - Broadcasts local edits to peers via Loro's subscribe_local_update -/// - Imports incoming edits from peers -/// -/// This runs alongside the existing async PDS sync for redundancy. -/// Session TTL in minutes - sessions are refreshed while active -const SESSION_TTL_MINUTES: u32 = 15; - -#[cfg(all(target_arch = "wasm32", target_os = "unknown"))] -#[component] -pub fn RealTimeSync(props: RealTimeSyncProps) -> Element { - use crate::collab_context::try_use_collab_debug; - use tokio::sync::mpsc; - use weaver_common::WeaverExt; - use weaver_common::transport::{CollabMessage, CollabSession, SessionEvent}; - - let collab_node = use_collab_node(); - let fetcher = use_context::(); - let mut session: Signal>> = use_signal(|| None); - // URI of our published session record (for cleanup) - let mut session_record_uri: Signal>> = use_signal(|| None); - // Debug state for display in editor debug panel (optional, may not be provided) - let debug_state = try_use_collab_debug(); - // Channel for sending local updates from Loro callback to async broadcast task - let mut update_tx: Signal>>> = use_signal(|| None); - // Channel for sending cursor updates - let mut cursor_tx: Signal)>>> = - use_signal(|| None); - // Keep subscription alive - let mut _subscription: Signal> = use_signal(|| None); - // Our assigned colour (set when we join) - let mut our_color: Signal = use_signal(|| 0x4ECDC4FF); - - let resource_ref = props.resource_ref.clone(); - let doc = props.document.clone(); - let mut presence = props.presence; - - // Broadcast cursor position when it changes - { - let doc = doc.clone(); - use_effect(move || { - // Read cursor to create reactive dependency - let cursor_state = doc.cursor.read(); - let selection = *doc.selection.read(); - - // Send cursor update if we have a channel - if let Some(ref tx) = *cursor_tx.read() { - let sel = selection.map(|s| (s.anchor, s.head)); - let _ = tx.send((cursor_state.offset, sel)); - } - }); - } - - // Join the gossip session when we have a node and resource ref - { - let resource_ref = resource_ref.clone(); - let doc_for_join = doc.clone(); - let fetcher = fetcher.clone(); - - use_effect(move || { - let Some(node) = collab_node.clone() else { - tracing::debug!("RealTimeSync: no CollabNode yet"); - return; - }; - - let Some(ref strong_ref) = resource_ref else { - tracing::debug!("RealTimeSync: no resource ref"); - return; - }; - - // Only join if we're not already in a session - if session.peek().is_some() { - return; - } - - let uri = strong_ref.uri.clone().into_static(); - tracing::info!("RealTimeSync: joining session for {}", uri); - - // Create channel for local update broadcasts - let (tx, mut rx) = mpsc::unbounded_channel::>(); - update_tx.set(Some(tx.clone())); - - // Create channel for cursor updates - let (ctx, mut crx) = mpsc::unbounded_channel::<(usize, Option<(usize, usize)>)>(); - cursor_tx.set(Some(ctx)); - - // Subscribe to local updates from Loro - fires when local changes are committed - let sub = doc_for_join - .loro_doc() - .subscribe_local_update(Box::new(move |update| { - tracing::debug!("RealTimeSync: local update ({} bytes)", update.len()); - if let Err(e) = tx.send(update.to_vec()) { - tracing::warn!("RealTimeSync: failed to queue update: {}", e); - } - true // Keep subscription active - })); - _subscription.set(Some(sub)); - - let doc_for_recv = doc_for_join.clone(); - let resource_ref_for_spawn = resource_ref.clone().unwrap(); - let fetcher = fetcher.clone(); - - spawn(async move { - // Derive topic from resource URI - let topic = CollabSession::topic_from_uri(uri.as_str()); - - // Wait for relay connection before discovering peers or publishing session - // Browser clients REQUIRE relay for peer connectivity - let relay_url = node.wait_for_relay().await; - tracing::info!( - relay_url = %relay_url, - "RealTimeSync: relay connection ready" - ); - - // Update debug state with node info - if let Some(mut ds) = debug_state { - ds.with_mut(|s| { - s.node_id = Some(node.node_id_string()); - s.relay_url = Some(relay_url.clone()); - }); - } - - // Discover existing session peers for bootstrap - let bootstrap_peers = match fetcher.find_session_peers(&uri).await { - Ok(peers) => { - tracing::info!("RealTimeSync: found {} existing peers", peers.len()); - if let Some(mut ds) = debug_state { - ds.with_mut(|s| s.discovered_peers = peers.len()); - } - for p in &peers { - tracing::info!( - did = %p.did, - node_id = %p.node_id, - relay_url = ?p.relay_url, - expires_at = ?p.expires_at, - "RealTimeSync: discovered peer" - ); - } - peers - .into_iter() - .filter_map(|p| { - weaver_common::transport::parse_node_id(&p.node_id).ok() - }) - .collect() - } - Err(e) => { - tracing::warn!("RealTimeSync: failed to find peers: {}", e); - if let Some(mut ds) = debug_state { - ds.with_mut(|s| s.last_error = Some(format!("peer discovery: {}", e))); - } - vec![] - } - }; - - // Publish our session record for peer discovery - let node_id_str = node.node_id_string(); - match fetcher - .create_collab_session( - &resource_ref_for_spawn, - &node_id_str, - Some(&relay_url), - Some(SESSION_TTL_MINUTES), - ) - .await - { - Ok(uri) => { - tracing::info!("RealTimeSync: published session record: {}", uri); - if let Some(mut ds) = debug_state { - ds.with_mut(|s| s.session_record_uri = Some(uri.to_string())); - } - session_record_uri.set(Some(uri)); - } - Err(e) => { - tracing::warn!("RealTimeSync: failed to publish session record: {}", e); - if let Some(mut ds) = debug_state { - ds.with_mut(|s| s.last_error = Some(format!("publish session: {}", e))); - } - } - } - - // Clone before join() consumes them - let node_for_discovery = node.clone(); - let bootstrap_peers_set = bootstrap_peers.clone(); - - match CollabSession::join(node, topic, bootstrap_peers).await { - Ok((collab_session, mut event_stream)) => { - let collab_session = Arc::new(collab_session); - session.set(Some(collab_session.clone())); - if let Some(mut ds) = debug_state { - ds.with_mut(|s| s.is_joined = true); - } - - tracing::info!("RealTimeSync: joined session for {}", uri); - - // Broadcast Join message to announce ourselves - let our_did = fetcher.current_did().await; - let display_name = if let Some(ref did) = our_did { - use jacquard::types::ident::AtIdentifier; - use weaver_api::sh_weaver::actor::ProfileDataViewInner; - - let ident = AtIdentifier::Did(did.clone()); - fetcher - .fetch_profile(&ident) - .await - .ok() - .and_then(|p| match &p.inner { - ProfileDataViewInner::ProfileView(pv) => { - pv.display_name.as_ref().map(|s| s.to_string()) - } - ProfileDataViewInner::ProfileViewDetailed(pv) => { - pv.display_name.as_ref().map(|s| s.to_string()) - } - _ => None, - }) - .unwrap_or_else(|| "Collaborator".to_string()) - } else { - "Collaborator".to_string() - }; - let join_msg = CollabMessage::Join { - did: our_did.map(|d| d.to_string()).unwrap_or_default(), - display_name, - }; - if let Err(e) = collab_session.broadcast(&join_msg).await { - tracing::warn!("RealTimeSync: failed to broadcast Join: {}", e); - } - - // Request sync from existing peers - // Convert our version vector to wire format - let our_vv = doc_for_recv.version_vector(); - let have_version: Vec<(u64, u64)> = our_vv - .iter() - .map(|(peer, counter)| (*peer, *counter as u64)) - .collect(); - let sync_request = CollabMessage::SyncRequest { have_version }; - if let Err(e) = collab_session.broadcast(&sync_request).await { - tracing::warn!("RealTimeSync: failed to broadcast SyncRequest: {}", e); - } else { - tracing::debug!("RealTimeSync: sent sync request ({} vv entries)", our_vv.len()); - } - - // Spawn TTL refresh task - keeps our session record alive - let session_uri_for_refresh = session_record_uri.clone(); - let fetcher_for_refresh = fetcher.clone(); - spawn(async move { - // Refresh every 5 minutes (TTL is 15 min, so plenty of buffer) - let mut interval = n0_future::time::interval( - n0_future::time::Duration::from_secs(5 * 60), - ); - loop { - interval.tick().await; - if let Some(uri) = session_uri_for_refresh.peek().clone() { - tracing::debug!("RealTimeSync: refreshing session TTL"); - if let Err(e) = fetcher_for_refresh - .refresh_collab_session(&uri, SESSION_TTL_MINUTES) - .await - { - tracing::warn!( - "RealTimeSync: failed to refresh session: {}", - e - ); - } - } - } - }); - - // Spawn broadcast task - sends local updates to gossip - let session_for_broadcast = collab_session.clone(); - spawn(async move { - while let Some(update_bytes) = rx.recv().await { - let msg = CollabMessage::LoroUpdate { - data: update_bytes, - version: vec![], // Version included in Loro update bytes - }; - if let Err(e) = session_for_broadcast.broadcast(&msg).await { - tracing::warn!("RealTimeSync: broadcast failed: {}", e); - } else { - tracing::debug!("RealTimeSync: broadcasted update"); - } - } - tracing::debug!("RealTimeSync: broadcast channel closed"); - }); - - // Spawn cursor broadcast task - sends cursor positions to gossip - let session_for_cursor = collab_session.clone(); - spawn(async move { - while let Some((position, selection)) = crx.recv().await { - let color = *our_color.peek(); - let msg = CollabMessage::Cursor { - position, - selection, - color, - }; - if let Err(e) = session_for_cursor.broadcast(&msg).await { - tracing::warn!("RealTimeSync: cursor broadcast failed: {}", e); - } - } - }); - - // Spawn periodic peer discovery task - // This handles the race condition where peers publish sessions - // at different times and might miss each other on initial discovery - let session_for_discovery = collab_session.clone(); - let fetcher_for_discovery = fetcher.clone(); - let uri_for_discovery = uri.clone(); - let our_node_id = node_for_discovery.node_id(); - let mut known_peers: std::collections::HashSet = - bootstrap_peers_set.iter().cloned().collect(); - spawn(async move { - // Check for new peers every 30 seconds - let mut interval = - n0_future::time::interval(n0_future::time::Duration::from_secs(30)); - loop { - interval.tick().await; - tracing::debug!("RealTimeSync: periodic discovery tick"); - match fetcher_for_discovery - .find_session_peers(&uri_for_discovery) - .await - { - Ok(peers) => { - tracing::info!( - "RealTimeSync: periodic discovery found {} session records", - peers.len() - ); - for p in &peers { - tracing::debug!( - " - peer: {} (relay: {:?}, expires: {:?})", - p.node_id, - p.relay_url, - p.expires_at - ); - } - // Filter: parse node ID, exclude ourselves, exclude already known - let new_peers: Vec<_> = peers - .into_iter() - .filter_map(|p| { - weaver_common::transport::parse_node_id(&p.node_id) - .ok() - }) - .filter(|id| *id != our_node_id) - .filter(|id| !known_peers.contains(id)) - .collect(); - - if !new_peers.is_empty() { - tracing::info!( - "RealTimeSync: periodic discovery found {} NEW peers", - new_peers.len() - ); - for p in &new_peers { - known_peers.insert(*p); - } - if let Err(e) = - session_for_discovery.join_peers(new_peers).await - { - tracing::warn!( - "RealTimeSync: failed to join discovered peers: {}", - e - ); - } - } - } - Err(e) => { - tracing::warn!( - "RealTimeSync: periodic peer discovery failed: {}", - e - ); - } - } - } - }); - - // Spawn event receiver task - receives updates from peers - let mut doc_for_recv = doc_for_recv.clone(); - let session_for_sync = collab_session.clone(); - spawn(async move { - use n0_future::StreamExt; - - while let Some(result) = event_stream.next().await { - let event = match result { - Ok(e) => e, - Err(e) => { - tracing::error!("RealTimeSync: event stream error: {}", e); - break; - } - }; - match event { - SessionEvent::Message { from, message } => { - match message { - CollabMessage::LoroUpdate { data, .. } => { - tracing::debug!( - "RealTimeSync: received update from {} ({} bytes)", - from, - data.len() - ); - if let Err(e) = doc_for_recv.import_updates(&data) { - tracing::warn!( - "RealTimeSync: failed to import update: {:?}", - e - ); - } - } - CollabMessage::Cursor { - position, - selection, - .. - } => { - // Add peer if not known (cursor might arrive before Join) - let mut p = presence.write(); - if !p.contains(&from) { - p.add_collaborator( - from, - "unknown".into(), - "Peer".into(), - ); - } - p.update_cursor(&from, position, selection); - } - CollabMessage::Join { did, display_name } => { - tracing::info!( - "RealTimeSync: peer joined: {} ({})", - display_name, - did - ); - presence.write().add_collaborator( - from, - did, - display_name, - ); - } - CollabMessage::Leave { did } => { - tracing::info!("RealTimeSync: peer left: {}", did); - presence.write().remove_collaborator(&from); - } - CollabMessage::SyncRequest { have_version } => { - tracing::debug!( - "RealTimeSync: sync request (have {} entries)", - have_version.len() - ); - // Convert their version vector from wire format - let their_vv: loro::VersionVector = have_version - .into_iter() - .map(|(peer, counter)| (peer, counter as i32)) - .collect(); - - // Export updates they don't have - if let Some(data) = - doc_for_recv.export_updates_from(&their_vv) - { - tracing::info!( - "RealTimeSync: sending {} bytes to sync peer", - data.len() - ); - let response = CollabMessage::SyncResponse { - data, - is_snapshot: false, - }; - if let Err(e) = - session_for_sync.broadcast(&response).await - { - tracing::warn!( - "RealTimeSync: failed to send sync response: {}", - e - ); - } - } else { - tracing::debug!( - "RealTimeSync: no updates to send (peer is up to date)" - ); - } - } - CollabMessage::SyncResponse { data, is_snapshot } => { - tracing::info!( - "RealTimeSync: received sync response ({} bytes, snapshot: {})", - data.len(), - is_snapshot - ); - if let Err(e) = doc_for_recv.import_updates(&data) { - tracing::warn!( - "RealTimeSync: failed to import sync response: {:?}", - e - ); - } - } - } - } - SessionEvent::PeerJoined(peer) => { - tracing::info!("RealTimeSync: peer connected: {}", peer); - // Add peer with placeholder name until they send Join - if !presence.read().contains(&peer) { - presence.write().add_collaborator( - peer, - "unknown".into(), - "Collaborator".into(), - ); - } - } - SessionEvent::PeerLeft(peer) => { - tracing::info!("RealTimeSync: peer disconnected: {}", peer); - presence.write().remove_collaborator(&peer); - } - SessionEvent::Joined => { - tracing::info!("RealTimeSync: joined gossip swarm"); - } - } - } - tracing::debug!("RealTimeSync: event stream ended"); - }); - } - Err(e) => { - tracing::error!("RealTimeSync: failed to join session: {}", e); - } - } - }); - }); - } - - // Cleanup: delete session record when component unmounts - { - let fetcher = fetcher.clone(); - use_drop(move || { - if let Some(uri) = session_record_uri.peek().clone() { - let fetcher = fetcher.clone(); - spawn(async move { - tracing::info!("RealTimeSync: cleaning up session record: {}", uri); - if let Err(e) = fetcher.delete_collab_session(&uri).await { - tracing::warn!("RealTimeSync: failed to delete session record: {}", e); - } - }); - } - }); - } - - // No UI - this is a background sync component - rsx! {} -} - -/// No-op for non-WASM builds. -#[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))] -#[component] -pub fn RealTimeSync(props: RealTimeSyncProps) -> Element { - rsx! {} -} - // ============================================================================ // Sync UI Components // ============================================================================ diff --git a/crates/weaver-app/src/components/editor/worker.rs b/crates/weaver-app/src/components/editor/worker.rs index 84551b8..d636c61 100644 --- a/crates/weaver-app/src/components/editor/worker.rs +++ b/crates/weaver-app/src/components/editor/worker.rs @@ -4,6 +4,9 @@ //! CPU-intensive operations like snapshot export and base64 encoding //! off the main thread. //! +//! When the `collab-worker` feature is enabled, also handles iroh P2P +//! networking for real-time collaboration. +//! //! Also handles embed fetching with a persistent cache to avoid re-fetching. #[cfg(all(target_family = "wasm", target_os = "unknown"))] @@ -53,6 +56,13 @@ pub enum WorkerInput { /// Node ID strings peers: Vec, }, + /// Announce ourselves to peers (sent after AddPeers) + BroadcastJoin { + /// Our DID + did: String, + /// Our display name + display_name: String, + }, /// Local cursor position changed BroadcastCursor { /// Cursor position @@ -110,66 +120,132 @@ pub enum WorkerOutput { PresenceUpdate(PresenceSnapshot), /// Collab session ended CollabStopped, + /// A new peer connected (coordinator should send BroadcastJoin) + PeerConnected, } #[cfg(all(target_family = "wasm", target_os = "unknown"))] mod worker_impl { use super::*; - use gloo_worker::{HandlerId, Worker, WorkerScope}; - - /// Editor worker that maintains a shadow Loro document. - pub struct EditorWorker { - /// Shadow Loro document - doc: Option, - /// Draft key for storage identification - draft_key: String, + use futures_util::sink::SinkExt; + use futures_util::stream::StreamExt; + use gloo_worker::reactor::{reactor, ReactorScope}; + use weaver_common::transport::CollaboratorInfo; + + #[cfg(feature = "collab-worker")] + use std::sync::Arc; + #[cfg(feature = "collab-worker")] + 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")] + enum CollabEvent { + RemoteUpdates { data: Vec }, + PresenceChanged(PresenceSnapshot), + PeerConnected, } - impl Worker for EditorWorker { - type Message = (); - type Input = WorkerInput; - type Output = WorkerOutput; - - fn create(_scope: &WorkerScope) -> Self { - Self { - doc: None, - draft_key: String::new(), - } + /// Editor reactor that maintains a shadow Loro document and handles collab. + #[reactor] + pub async fn EditorReactor(mut scope: ReactorScope) { + let mut doc: Option = None; + let mut draft_key = String::new(); + + // Collab state (only used when collab-worker feature enabled) + #[cfg(feature = "collab-worker")] + let mut collab_node: Option> = None; + #[cfg(feature = "collab-worker")] + let mut collab_session: Option> = None; + #[cfg(feature = "collab-worker")] + let mut collab_event_rx: Option> = None; + #[cfg(feature = "collab-worker")] + const OUR_COLOR: u32 = 0x4ECDC4FF; + + // Helper enum for racing coordinator messages vs collab events + #[cfg(feature = "collab-worker")] + enum RaceResult { + CoordinatorMsg(Option), + CollabEvent(Option), } - fn update(&mut self, _scope: &WorkerScope, _msg: Self::Message) {} - - fn received(&mut self, scope: &WorkerScope, msg: Self::Input, id: HandlerId) { - match msg { + loop { + // Race between coordinator messages and collab events + #[cfg(feature = "collab-worker")] + 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) }; + let collab_fut = async { RaceResult::CollabEvent(event_rx.recv().await) }; + coord_fut.race(collab_fut).await + } else { + RaceResult::CoordinatorMsg(scope.next().await) + }; + + #[cfg(feature = "collab-worker")] + match race_result { + RaceResult::CollabEvent(Some(event)) => { + match event { + CollabEvent::RemoteUpdates { data } => { + if let Err(e) = scope.send(WorkerOutput::RemoteUpdates { data }).await { + tracing::error!("Failed to send RemoteUpdates to coordinator: {e}"); + } + } + CollabEvent::PresenceChanged(snapshot) => { + if let Err(e) = scope.send(WorkerOutput::PresenceUpdate(snapshot)).await { + tracing::error!("Failed to send PresenceUpdate to coordinator: {e}"); + } + } + CollabEvent::PeerConnected => { + if let Err(e) = scope.send(WorkerOutput::PeerConnected).await { + tracing::error!("Failed to send PeerConnected to coordinator: {e}"); + } + } + } + continue; // Go back to racing + } + RaceResult::CollabEvent(None) => { + // Collab channel closed, continue with just coordinator messages + collab_event_rx = None; + continue; + } + RaceResult::CoordinatorMsg(None) => break, // Coordinator closed + RaceResult::CoordinatorMsg(Some(msg)) => { + // Fall through to message handling below + tracing::debug!(?msg, "Worker: received message"); + match msg { WorkerInput::Init { snapshot, - draft_key, + draft_key: key, } => { - let doc = loro::LoroDoc::new(); + let new_doc = loro::LoroDoc::new(); if !snapshot.is_empty() { - if let Err(e) = doc.import(&snapshot) { - scope.respond( - id, - WorkerOutput::Error { + if let Err(e) = new_doc.import(&snapshot) { + if let Err(send_err) = scope + .send(WorkerOutput::Error { message: format!("Failed to import snapshot: {e}"), - }, - ); - return; + }) + .await + { + tracing::error!("Failed to send Error to coordinator: {send_err}"); + } + continue; } } - self.doc = Some(doc); - self.draft_key = draft_key; - scope.respond(id, WorkerOutput::Ready); + doc = Some(new_doc); + draft_key = key; + if let Err(e) = scope.send(WorkerOutput::Ready).await { + tracing::error!("Failed to send Ready to coordinator: {e}"); + } } WorkerInput::ApplyUpdates { updates } => { - if let Some(ref doc) = self.doc { + if let Some(ref doc) = doc { if let Err(e) = doc.import(&updates) { - // Log but don't fail - updates can be stale tracing::warn!("Worker failed to import updates: {e}"); } } - // No response for updates - fire and forget } WorkerInput::ExportSnapshot { @@ -177,45 +253,45 @@ mod worker_impl { editing_uri, editing_cid, } => { - let Some(ref doc) = self.doc else { - scope.respond( - id, - WorkerOutput::Error { + let Some(ref doc) = doc else { + if let Err(e) = scope + .send(WorkerOutput::Error { message: "No document initialized".into(), - }, - ); - return; + }) + .await + { + tracing::error!("Failed to send Error to coordinator: {e}"); + } + continue; }; - // Export snapshot let export_start = crate::perf::now(); let snapshot_bytes = match doc.export(loro::ExportMode::Snapshot) { Ok(bytes) => bytes, Err(e) => { - scope.respond( - id, - WorkerOutput::Error { + if let Err(send_err) = scope + .send(WorkerOutput::Error { message: format!("Export failed: {e}"), - }, - ); - return; + }) + .await + { + tracing::error!("Failed to send Error to coordinator: {send_err}"); + } + continue; } }; let export_ms = crate::perf::now() - export_start; - // Base64 encode let encode_start = crate::perf::now(); let b64_snapshot = BASE64.encode(&snapshot_bytes); let encode_ms = crate::perf::now() - encode_start; - // Extract content and title let content = doc.get_text("content").to_string(); let title = doc.get_text("title").to_string(); - scope.respond( - id, - WorkerOutput::Snapshot { - draft_key: self.draft_key.clone(), + if let Err(e) = scope + .send(WorkerOutput::Snapshot { + draft_key: draft_key.clone(), b64_snapshot, content, title, @@ -224,16 +300,353 @@ mod worker_impl { editing_cid, export_ms, encode_ms, - }, - ); + }) + .await + { + tracing::error!("Failed to send Snapshot to coordinator: {e}"); + } + } + + // ============================================================ + // Collab handlers - full impl when collab-worker feature enabled + // ============================================================ + #[cfg(feature = "collab-worker")] + WorkerInput::StartCollab { + topic, + bootstrap_peers, + } => { + // Spawn CollabNode + let node = match CollabNode::spawn(None).await { + Ok(n) => n, + Err(e) => { + if let Err(send_err) = scope + .send(WorkerOutput::Error { + message: format!("Failed to spawn CollabNode: {e}"), + }) + .await + { + tracing::error!("Failed to send Error to coordinator: {send_err}"); + } + continue; + } + }; + + // Wait for relay connection + let relay_url = node.wait_for_relay().await; + let node_id = node.node_id_string(); + + // Send ready so main thread can create session record + if let Err(e) = scope + .send(WorkerOutput::CollabReady { + node_id, + relay_url: Some(relay_url), + }) + .await + { + tracing::error!("Failed to send CollabReady to coordinator: {e}"); + } + + collab_node = Some(node.clone()); + + // Parse bootstrap peers + let peers: Vec<_> = bootstrap_peers + .iter() + .filter_map(|s| parse_node_id(s).ok()) + .collect(); + + // Join gossip session + let topic_id = TopicId::from_bytes(topic); + match CollabSession::join(node, topic_id, peers).await { + Ok((session, mut events)) => { + let session = Arc::new(session); + collab_session = Some(session.clone()); + if let Err(e) = scope.send(WorkerOutput::CollabJoined).await { + tracing::error!("Failed to send CollabJoined to coordinator: {e}"); + } + + // 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(); + collab_event_rx = Some(event_rx); + + // Spawn event handler task that sends via channel + wasm_bindgen_futures::spawn_local(async move { + let mut presence = PresenceTracker::new(); + + while let Some(Ok(event)) = events.next().await { + match event { + SessionEvent::Message { from, message } => { + match message { + CollabMessage::LoroUpdate { data, .. } => { + if event_tx.send(CollabEvent::RemoteUpdates { data }).is_err() { + tracing::warn!("Collab event channel closed"); + return; + } + } + CollabMessage::Join { did, display_name } => { + tracing::info!(%from, %did, %display_name, "Received Join message"); + presence.add_collaborator(from, did, display_name); + if event_tx.send(CollabEvent::PresenceChanged( + presence_to_snapshot(&presence), + )).is_err() { + tracing::warn!("Collab event channel closed"); + return; + } + } + CollabMessage::Leave { .. } => { + presence.remove_collaborator(&from); + if event_tx.send(CollabEvent::PresenceChanged( + presence_to_snapshot(&presence), + )).is_err() { + tracing::warn!("Collab event channel closed"); + return; + } + } + CollabMessage::Cursor { + position, + 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(&from, position, selection); + if event_tx.send(CollabEvent::PresenceChanged( + presence_to_snapshot(&presence), + )).is_err() { + tracing::warn!("Collab event channel closed"); + return; + } + } + _ => {} + } + } + 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() { + tracing::warn!("Collab event channel closed"); + return; + } + } + SessionEvent::PeerLeft(peer) => { + presence.remove_collaborator(&peer); + if event_tx.send(CollabEvent::PresenceChanged( + presence_to_snapshot(&presence), + )).is_err() { + tracing::warn!("Collab event channel closed"); + return; + } + } + SessionEvent::Joined => {} + } + } + }); + } + Err(e) => { + if let Err(send_err) = scope + .send(WorkerOutput::Error { + message: format!("Failed to join session: {e}"), + }) + .await + { + tracing::error!("Failed to send Error to coordinator: {send_err}"); + } + } + } + } + + #[cfg(feature = "collab-worker")] + WorkerInput::BroadcastUpdate { data } => { + if let Some(ref session) = collab_session { + let msg = CollabMessage::LoroUpdate { + data, + version: vec![], + }; + if let Err(e) = session.broadcast(&msg).await { + tracing::warn!("Broadcast failed: {e}"); + } + } + } + + #[cfg(feature = "collab-worker")] + WorkerInput::BroadcastCursor { position, selection } => { + if let Some(ref session) = collab_session { + tracing::debug!(position, ?selection, "Worker: broadcasting cursor"); + let msg = CollabMessage::Cursor { + position, + selection, + color: OUR_COLOR, + }; + if let Err(e) = session.broadcast(&msg).await { + tracing::warn!("Cursor broadcast failed: {e}"); + } + } else { + tracing::debug!(position, ?selection, "Worker: BroadcastCursor but no session"); + } + } + + #[cfg(feature = "collab-worker")] + 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(); + tracing::info!(parsed_count = peer_ids.len(), "Worker: joining peers"); + if let Err(e) = session.join_peers(peer_ids).await { + tracing::warn!("Failed to add peers: {e}"); + } + } else { + tracing::warn!("Worker: AddPeers but no collab_session"); + } + } + + #[cfg(feature = "collab-worker")] + WorkerInput::BroadcastJoin { did, display_name } => { + if let Some(ref session) = collab_session { + let join_msg = CollabMessage::Join { did, display_name }; + if let Err(e) = session.broadcast(&join_msg).await { + tracing::warn!("Failed to broadcast Join: {e}"); + } + } + } + + #[cfg(feature = "collab-worker")] + WorkerInput::StopCollab => { + collab_session = None; + collab_node = None; + collab_event_rx = None; + 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"))] + { + let Some(msg) = scope.next().await else { break }; + tracing::debug!(?msg, "Worker: received message"); + match msg { + WorkerInput::Init { snapshot, draft_key: key } => { + let new_doc = loro::LoroDoc::new(); + if !snapshot.is_empty() { + if let Err(e) = new_doc.import(&snapshot) { + if let Err(send_err) = scope + .send(WorkerOutput::Error { + message: format!("Failed to import snapshot: {e}"), + }) + .await + { + tracing::error!("Failed to send Error to coordinator: {send_err}"); + } + continue; + } + } + doc = Some(new_doc); + draft_key = key; + if let Err(e) = scope.send(WorkerOutput::Ready).await { + tracing::error!("Failed to send Ready to coordinator: {e}"); + } + } + WorkerInput::ApplyUpdates { updates } => { + if let Some(ref doc) = doc { + if let Err(e) = doc.import(&updates) { + tracing::warn!("Worker failed to import updates: {e}"); + } + } + } + WorkerInput::ExportSnapshot { cursor_offset, editing_uri, editing_cid } => { + let Some(ref doc) = doc else { + if let Err(e) = scope.send(WorkerOutput::Error { message: "No document initialized".into() }).await { + tracing::error!("Failed to send Error to coordinator: {e}"); + } + continue; + }; + let export_start = crate::perf::now(); + let snapshot_bytes = match doc.export(loro::ExportMode::Snapshot) { + Ok(bytes) => bytes, + Err(e) => { + if let Err(send_err) = scope.send(WorkerOutput::Error { message: format!("Export failed: {e}") }).await { + tracing::error!("Failed to send Error to coordinator: {send_err}"); + } + continue; + } + }; + let export_ms = crate::perf::now() - export_start; + let encode_start = crate::perf::now(); + let b64_snapshot = BASE64.encode(&snapshot_bytes); + let encode_ms = crate::perf::now() - encode_start; + let content = doc.get_text("content").to_string(); + let title = doc.get_text("title").to_string(); + if let Err(e) = scope.send(WorkerOutput::Snapshot { + draft_key: draft_key.clone(), b64_snapshot, content, title, + cursor_offset, editing_uri, editing_cid, export_ms, encode_ms, + }).await { + tracing::error!("Failed to send Snapshot to coordinator: {e}"); + } + } + // Collab stubs for non-collab-worker build + 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}"); + } + } + WorkerInput::BroadcastUpdate { .. } => {} + WorkerInput::AddPeers { .. } => {} + WorkerInput::BroadcastJoin { .. } => {} + WorkerInput::BroadcastCursor { .. } => {} + WorkerInput::StopCollab => { + if let Err(e) = scope.send(WorkerOutput::CollabStopped).await { + tracing::error!("Failed to send CollabStopped to coordinator: {e}"); + } + } } } } } + + /// Convert PresenceTracker to serializable PresenceSnapshot. + #[cfg(feature = "collab-worker")] + fn presence_to_snapshot(tracker: &PresenceTracker) -> PresenceSnapshot { + let collaborators = tracker + .collaborators() + .map(|c| CollaboratorInfo { + node_id: c.node_id.to_string(), + did: c.did.clone(), + display_name: c.display_name.clone(), + color: c.color, + cursor_position: c.cursor.as_ref().map(|cur| cur.position), + selection: c.cursor.as_ref().and_then(|cur| cur.selection), + }) + .collect(); + + PresenceSnapshot { + collaborators, + peer_count: tracker.len(), + } + } } #[cfg(all(target_family = "wasm", target_os = "unknown"))] -pub use worker_impl::EditorWorker; +pub use worker_impl::EditorReactor; // ============================================================================ // Embed Worker - fetches and caches AT Protocol embeds diff --git a/crates/weaver-app/src/env.rs b/crates/weaver-app/src/env.rs index f8a1ad7..b8c902b 100644 --- a/crates/weaver-app/src/env.rs +++ b/crates/weaver-app/src/env.rs @@ -1,11 +1,11 @@ // This file is automatically generated by build.rs #[allow(unused)] -pub const WEAVER_APP_ENV: &'static str = "prod"; +pub const WEAVER_APP_ENV: &'static str = "dev"; #[allow(unused)] -pub const WEAVER_APP_HOST: &'static str = "https://alpha.weaver.sh"; +pub const WEAVER_APP_HOST: &'static str = "http://localhost"; #[allow(unused)] -pub const WEAVER_APP_DOMAIN: &'static str = "https://alpha.weaver.sh"; +pub const WEAVER_APP_DOMAIN: &'static str = ""; #[allow(unused)] pub const WEAVER_PORT: &'static str = "8080"; #[allow(unused)] @@ -13,7 +13,7 @@ pub const WEAVER_APP_SCOPES: &'static str = "atproto transition:generic"; #[allow(unused)] pub const WEAVER_CLIENT_NAME: &'static str = "Weaver"; #[allow(unused)] -pub const WEAVER_LOGO_URI: &'static str = "https://alpha.weaver.sh/favicon.ico"; +pub const WEAVER_LOGO_URI: &'static str = ""; #[allow(unused)] pub const WEAVER_TOS_URI: &'static str = ""; #[allow(unused)] diff --git a/crates/weaver-app/src/lib.rs b/crates/weaver-app/src/lib.rs index 326faed..6eb3693 100644 --- a/crates/weaver-app/src/lib.rs +++ b/crates/weaver-app/src/lib.rs @@ -146,11 +146,8 @@ pub fn App() -> Element { document::Link { rel: "stylesheet", href: "https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:ital,wght@0,200;0,300;0,400;0,500;0,600;0,700;1,200;1,300;1,400;1,500;1,600;1,700&family=IBM+Plex+Sans:ital,wght@0,100..700;1,100..700&family=IBM+Plex+Serif:ital,wght@0,200;0,300;0,400;0,500;0,600;0,700;1,200;1,300;1,400;1,500;1,600;1,700&display=swap" } // App shell styles (depends on theme variables) document::Link { rel: "stylesheet", href: MAIN_CSS } - // P2P collaboration node (provides CollabNode context for real-time sync) - collab_context::CollabProvider { - components::toast::ToastProvider { - Router:: {} - } + components::toast::ToastProvider { + Router:: {} } } } diff --git a/crates/weaver-app/src/views/entry.rs b/crates/weaver-app/src/views/entry.rs index 2c80268..fdb783b 100644 --- a/crates/weaver-app/src/views/entry.rs +++ b/crates/weaver-app/src/views/entry.rs @@ -34,6 +34,7 @@ pub fn StandaloneEntry( .map(|t| t.as_ref()) .unwrap_or("Untitled"); + tracing::info!("Entry: {title}"); let author_handle = entry_view .authors .first() @@ -177,6 +178,8 @@ pub fn NotebookEntryByRkey( .map(|p| p.as_ref().to_string()) .unwrap_or_else(|| title.to_string()); + tracing::info!("Entry: {entry_path} - {title}"); + let author_handle = entry_view .authors .first() diff --git a/crates/weaver-common/src/lib.rs b/crates/weaver-common/src/lib.rs index d89e1d6..180d619 100644 --- a/crates/weaver-common/src/lib.rs +++ b/crates/weaver-common/src/lib.rs @@ -10,6 +10,9 @@ pub mod worker_rt; // Re-export jacquard for convenience pub use agent::{SessionPeer, WeaverExt}; pub use error::WeaverError; + +// Re-export blake3 for topic hashing +pub use blake3; pub use resolve::{EntryIndex, ExtractedRef, RefCollector, ResolvedContent, ResolvedEntry}; pub use jacquard; diff --git a/crates/weaver-common/src/transport/presence.rs b/crates/weaver-common/src/transport/presence.rs index 9eaa85a..9bd6b69 100644 --- a/crates/weaver-common/src/transport/presence.rs +++ b/crates/weaver-common/src/transport/presence.rs @@ -38,7 +38,7 @@ pub struct Collaborator { } /// Tracks all collaborators in a session. -#[derive(Debug, Default)] +#[derive(Debug, Default, Clone)] pub struct PresenceTracker { /// Collaborators by EndpointId. collaborators: HashMap, diff --git a/crates/weaver-common/src/transport/session.rs b/crates/weaver-common/src/transport/session.rs index 7fc9482..b94627a 100644 --- a/crates/weaver-common/src/transport/session.rs +++ b/crates/weaver-common/src/transport/session.rs @@ -92,12 +92,20 @@ impl CollabSession { } // Subscribe to the gossip topic - let (sender, receiver) = node - .gossip() - .subscribe_and_join(topic, bootstrap_peers) - .await - .map_err(|e| SessionError::Subscribe(Box::new(e)))? - .split(); + // Use subscribe (non-blocking) if no bootstrap peers, otherwise subscribe_and_join + let (sender, receiver) = if bootstrap_peers.is_empty() { + node.gossip() + .subscribe(topic, vec![]) + .await + .map_err(|e| SessionError::Subscribe(Box::new(e)))? + .split() + } else { + node.gossip() + .subscribe_and_join(topic, bootstrap_peers) + .await + .map_err(|e| SessionError::Subscribe(Box::new(e)))? + .split() + }; tracing::info!("CollabSession: subscribed to gossip topic");